'use client'; import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, CheckCircle2, HardDrive, Loader2, RefreshCw, ServerCog, XCircle, } from 'lucide-react'; import { toast } from 'sonner'; import { PageHeader } from '@/components/shared/page-header'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { SettingsFormCard, type SettingFieldDef, } from '@/components/admin/shared/settings-form-card'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; type BackendName = 's3' | 'filesystem'; interface StorageStatus { backend: BackendName; fileCount: number; totalBytes: number; tablesTracked: string[]; } interface MigrationResult { rowsConsidered: number; rowsMigrated: number; rowsSkippedAlreadyDone: number; totalBytes: number; flipped: boolean; dryRun: boolean; } const S3_FIELDS: SettingFieldDef[] = [ { key: 'storage_s3_endpoint', label: 'S3 endpoint URL', description: 'For AWS use https://s3..amazonaws.com. For MinIO/Backblaze/R2/Wasabi/Tigris paste the provider-supplied URL.', type: 'string', placeholder: 'https://s3.us-east-1.amazonaws.com', defaultValue: '', }, { key: 'storage_s3_region', label: 'S3 region', description: 'AWS region code (e.g. us-east-1, eu-west-1). Use "auto" for Cloudflare R2.', type: 'string', placeholder: 'us-east-1', defaultValue: 'us-east-1', }, { key: 'storage_s3_bucket', label: 'S3 bucket name', description: 'Existing bucket the CRM should read/write to. Must already exist.', type: 'string', placeholder: 'port-nimara-storage', defaultValue: '', }, { key: 'storage_s3_access_key', label: 'S3 access key', description: 'IAM access key id (or provider equivalent).', type: 'string', placeholder: 'AKIA…', defaultValue: '', }, { key: 'storage_s3_secret_key_encrypted', label: 'S3 secret key', description: 'Stored AES-encrypted at rest; the field shows blank after save and is replaced only when you type a new value.', type: 'password', placeholder: '(unchanged)', defaultValue: '', }, { key: 'storage_s3_force_path_style', label: 'Force path-style URLs', description: 'On for MinIO and most self-hosted S3-compatible servers. Off for AWS S3 (which uses virtual-hosted-style by default).', type: 'boolean', defaultValue: false, }, ]; const FS_FIELDS: SettingFieldDef[] = [ { key: 'storage_filesystem_root', label: 'Filesystem root path', description: 'Absolute path on the server where files are stored. Must be writable by the CRM process. Single-node deployments only.', type: 'string', placeholder: '/var/lib/port-nimara/storage', defaultValue: '/var/lib/port-nimara/storage', }, ]; export function StorageAdminPanel() { const queryClient = useQueryClient(); const [confirmOpen, setConfirmOpen] = useState(false); const [dryRun, setDryRun] = useState(null); const [confirmMode, setConfirmMode] = useState<'switch-only' | 'switch-and-migrate'>( 'switch-and-migrate', ); const [testResult, setTestResult] = useState<{ ok: boolean; error?: string } | null>(null); const status = useQuery({ queryKey: ['admin', 'storage', 'status'], queryFn: () => apiFetch<{ data: StorageStatus }>('/api/v1/admin/storage'), }); const dryRunMutation = useMutation({ mutationFn: async (opts: { from: BackendName; to: BackendName }) => apiFetch<{ data: MigrationResult }>('/api/v1/admin/storage/migrate', { method: 'POST', body: JSON.stringify({ ...opts, dryRun: true }), }), onSuccess: (result) => { setDryRun(result.data); setConfirmOpen(true); }, onError: (e) => toastError(e), }); const migrateMutation = useMutation({ mutationFn: async (opts: { from: BackendName; to: BackendName; skipMigration: boolean }) => apiFetch<{ data: MigrationResult }>('/api/v1/admin/storage/migrate', { method: 'POST', body: JSON.stringify({ ...opts, dryRun: false }), }), onSuccess: (result) => { setConfirmOpen(false); setDryRun(null); const copied = result.data.rowsMigrated ?? 0; toast.success( copied > 0 ? `Storage migration complete (${copied} file${copied === 1 ? '' : 's'} copied)` : 'Storage backend switched (no migration performed)', ); queryClient.invalidateQueries({ queryKey: ['admin', 'storage', 'status'] }); }, onError: (e) => toastError(e), }); const testMutation = useMutation({ mutationFn: async () => apiFetch<{ ok: boolean; error?: string }>('/api/v1/admin/storage', { method: 'POST', }), onSuccess: (r) => setTestResult(r), onError: (e: Error) => setTestResult({ ok: false, error: e.message }), }); if (status.isLoading) { return (
Loading storage status…
); } if (status.isError || !status.data?.data) { return
Failed to load storage status.
; } const s = status.data.data; const otherBackend: BackendName = s.backend === 's3' ? 'filesystem' : 's3'; function openConfirm(mode: 'switch-only' | 'switch-and-migrate') { setConfirmMode(mode); if (mode === 'switch-and-migrate') { // Dry run first so the dialog shows the exact rows + bytes. dryRunMutation.mutate({ from: s.backend, to: otherBackend }); } else { // Switch-only — no dry run, just show the warning. setDryRun(null); setConfirmOpen(true); } } return (
{/* STEP 1: configure connection details for the OTHER backend so the admin can prep + test BEFORE attempting any switch. */} {testResult && (
{testResult.ok ? (
Connection OK — round-trip succeeded.
) : (
{testResult.error ?? 'Connection failed'}
)}
)}
} /> {/* STEP 2: visualize current state + switch options. Both options are gated behind a confirmation dialog. */}
{s.backend === 's3' ? ( ) : ( )}
Active backend: {s.backend} {s.backend === 's3' ? 'Files stored in an S3-compatible object store (AWS, MinIO, R2, B2, Wasabi, Tigris).' : 'Files stored on the local filesystem under storage_filesystem_root. Single-node only.'}
Tracked tables
{s.tablesTracked.length === 0 ? '(none yet)' : s.tablesTracked.join(', ')}
File count
{s.fileCount}

Switch active backend

Switch + migrate copies every existing file to the new backend then flips the pointer atomically. Reversible with a follow-up reverse-migration.{' '} Switch only flips the pointer immediately — old files become inaccessible until you migrate them or revert the backend.

Backup notes {s.backend === 's3' ? (

S3 mode: configure your provider's lifecycle / replication / versioning policies as your primary backup. The CRM does not duplicate object storage in its own backups.

) : (

Filesystem mode: include the storage root directory in your backup tool (restic, borg, snapshots). It sits next to the database; the two should be backed up together.

)}

Filesystem mode refuses to start when MULTI_NODE_DEPLOYMENT=true. For multi-node deployments, switch to an S3-compatible backend.

{confirmMode === 'switch-and-migrate' ? `Switch + migrate to ${otherBackend}?` : `Switch active backend to ${otherBackend}?`} {confirmMode === 'switch-and-migrate' ? 'Copy every existing file to the new backend, verify each via sha256, then atomically flip the active backend. The dry-run summary below shows what will move.' : 'Flip the pointer immediately. Existing files stay on the old backend and become inaccessible until you migrate them or revert. Use this when you intentionally want a fresh storage tier (rare).'} {dryRun && confirmMode === 'switch-and-migrate' && (
Rows considered
{dryRun.rowsConsidered}
Already migrated (resumable)
{dryRun.rowsSkippedAlreadyDone}
Total bytes
{Math.round(dryRun.totalBytes / 1024)} KB
)} {confirmMode === 'switch-only' && (
Warning: {s.fileCount} existing file {s.fileCount === 1 ? '' : 's'} on {s.backend} will not be reachable from the CRM after the switch unless you migrate them later. This is rarely the right choice — prefer Switch + migrate.
)}
); }