'use client'; import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { CheckCircle2, HardDrive, Loader2, RefreshCw, ServerCog, XCircle } from 'lucide-react'; 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 { apiFetch } from '@/lib/api/client'; 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; } export function StorageAdminPanel() { const queryClient = useQueryClient(); const [confirmOpen, setConfirmOpen] = useState(false); const [dryRun, setDryRun] = useState(null); 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); }, }); const migrateMutation = useMutation({ mutationFn: async (opts: { from: BackendName; to: BackendName }) => apiFetch<{ data: MigrationResult }>('/api/v1/admin/storage/migrate', { method: 'POST', body: JSON.stringify({ ...opts, dryRun: false }), }), onSuccess: () => { setConfirmOpen(false); setDryRun(null); queryClient.invalidateQueries({ queryKey: ['admin', 'storage', 'status'] }); }, }); 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'; return (
{s.backend === 's3' ? ( ) : ( )}
Active backend: {s.backend} {s.backend === 's3' ? 'Files stored in an S3-compatible object store (MinIO, AWS S3, Backblaze B2, Cloudflare R2, Wasabi, Tigris).' : 'Files stored on the local filesystem under storage_filesystem_root. Single-node deployments only.'}
Tracked tables
{s.tablesTracked.length === 0 ? '(none yet — Phase 6b)' : s.tablesTracked.join(', ')}
File count
{s.fileCount}
{s.backend === 's3' && ( )}
{testResult && (
{testResult.ok ? (
Connection OK — round-trip succeeded.
) : (
{testResult.error ?? 'Connection failed'}
)}
)}
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.

Switch storage backend Move all tracked files from the current backend to the new backend, verify each file via sha256, then atomically flip the active backend. {dryRun && (
Rows considered
{dryRun.rowsConsidered}
Already migrated (resumable)
{dryRun.rowsSkippedAlreadyDone}
Total bytes
{Math.round(dryRun.totalBytes / 1024)} KB
)}
); }