import { useEffect, useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; import { Combobox } from '@/components/ui/combobox'; import { TogglePill } from '@/components/ui/toggle-pill'; import { ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { apiFetch } from '@/lib/api'; import { formatBytes } from '@/lib/utils'; import { useLicense } from '@/context/LicenseContext'; import { Cloud, CloudOff, RefreshCw, CheckCircle2, Loader2, Trash2, Download, ChevronLeft, ChevronRight } from 'lucide-react'; import { SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; type Provider = 'disabled' | 'sencho' | 'custom'; interface CustomConfig { endpoint: string; region: string; bucket: string; access_key: string; secret_key: string; path_prefix: string; auto_upload: boolean; } interface ConfigResponse { provider: Provider; sencho_provisioned: boolean; sencho_provisioned_at: string | null; custom: CustomConfig; } interface UsageResponse { used_bytes: number; quota_bytes: number; object_count: number; } interface CloudSnapshotEntry { objectKey: string; sizeBytes: number; lastModified: string | null; snapshotId: number | null; } const EMPTY_CUSTOM: CustomConfig = { endpoint: '', region: '', bucket: '', access_key: '', secret_key: '', path_prefix: 'sencho/', auto_upload: false, }; const BASE_PROVIDER_OPTIONS = [ { value: 'disabled', label: 'Disabled' }, { value: 'custom', label: 'Custom S3 (BYOB)' }, ]; const SENCHO_PROVIDER_OPTION = { value: 'sencho', label: 'Sencho Cloud Backup (included)' }; const PANEL_CLASS = 'rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3'; const PAGE_SIZE = 10; export function CloudBackupSection() { const { isPaid } = useLicense(); const providerOptions = isPaid ? [BASE_PROVIDER_OPTIONS[0], SENCHO_PROVIDER_OPTION, BASE_PROVIDER_OPTIONS[1]] : BASE_PROVIDER_OPTIONS; const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [provider, setProvider] = useState('disabled'); const [senchoProvisioned, setSenchoProvisioned] = useState(false); const [custom, setCustom] = useState(EMPTY_CUSTOM); const [originalSecretSaved, setOriginalSecretSaved] = useState(false); const [usage, setUsage] = useState(null); const [snapshots, setSnapshots] = useState([]); const [testing, setTesting] = useState(false); const [provisioning, setProvisioning] = useState(false); const [deleteKey, setDeleteKey] = useState(null); const [page, setPage] = useState(0); const totalPages = Math.max(1, Math.ceil(snapshots.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages - 1); const pagedSnapshots = snapshots.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE); const needsPagination = snapshots.length > PAGE_SIZE; const loadConfig = useCallback(async () => { try { const res = await apiFetch('/cloud-backup/config'); if (!res.ok) throw new Error(`Failed to load config (${res.status})`); const data: ConfigResponse = await res.json(); setProvider(data.provider); setSenchoProvisioned(data.sencho_provisioned); setCustom({ ...data.custom, secret_key: '' }); setOriginalSecretSaved(!!data.custom.secret_key); } catch (err) { toast.error((err as Error)?.message || 'Failed to load cloud backup config.'); } finally { setLoading(false); } }, []); const loadUsage = useCallback(async () => { try { const res = await apiFetch('/cloud-backup/usage'); if (res.ok) setUsage(await res.json()); } catch { // Usage is informational; failures shouldn't surface as toasts. } }, []); const loadSnapshots = useCallback(async () => { try { const res = await apiFetch('/cloud-backup/snapshots'); if (res.ok) setSnapshots(await res.json()); } catch { // Best-effort; the panel renders empty when listing fails. } }, []); useEffect(() => { loadConfig(); }, [loadConfig]); useEffect(() => { if (provider === 'sencho' && senchoProvisioned) loadUsage(); }, [provider, senchoProvisioned, loadUsage]); useEffect(() => { if (provider !== 'disabled') loadSnapshots(); else setSnapshots([]); }, [provider, loadSnapshots]); const handleProviderChange = async (next: string) => { const nextProvider = next as Provider; setProvider(nextProvider); if (nextProvider === 'sencho' && !senchoProvisioned) return; setSaving(true); try { const body = nextProvider === 'custom' ? { provider: nextProvider, custom: { ...custom, secret_key: '' } } : { provider: nextProvider }; const res = await apiFetch('/cloud-backup/config', { method: 'PUT', body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err?.error || 'Failed to save provider'); } toast.success('Cloud backup provider updated.'); } catch (err) { toast.error((err as Error)?.message || 'Failed to update provider.'); } finally { setSaving(false); } }; const handleSaveCustom = async () => { setSaving(true); try { const payload = { provider: 'custom', custom: { endpoint: custom.endpoint, region: custom.region, bucket: custom.bucket, access_key: custom.access_key, secret_key: custom.secret_key || (originalSecretSaved ? '***' : ''), path_prefix: custom.path_prefix, auto_upload: custom.auto_upload, }, }; const res = await apiFetch('/cloud-backup/config', { method: 'PUT', body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err?.error || 'Failed to save configuration'); } toast.success('Custom S3 configuration saved.'); setCustom(c => ({ ...c, secret_key: '' })); setOriginalSecretSaved(true); loadSnapshots(); } catch (err) { toast.error((err as Error)?.message || 'Failed to save configuration.'); } finally { setSaving(false); } }; const handleTest = async () => { setTesting(true); try { const res = await apiFetch('/cloud-backup/test', { method: 'POST' }); const data = await res.json().catch(() => ({})); if (data.success) toast.success('Connection successful.'); else toast.error(data.error || 'Connection test failed.'); } catch (err) { toast.error((err as Error)?.message || 'Connection test failed.'); } finally { setTesting(false); } }; const handleProvision = async () => { setProvisioning(true); try { const res = await apiFetch('/cloud-backup/provision', { method: 'POST' }); const data = await res.json().catch(() => ({})); if (!res.ok || data?.error) throw new Error(data?.error || 'Provisioning failed.'); toast.success('Sencho Cloud Backup activated.'); setSenchoProvisioned(true); await Promise.all([loadConfig(), loadUsage(), loadSnapshots()]); } catch (err) { toast.error((err as Error)?.message || 'Provisioning failed.'); } finally { setProvisioning(false); } }; const handleAutoUploadToggle = async (next: boolean) => { setCustom(c => ({ ...c, auto_upload: next })); try { const res = await apiFetch('/cloud-backup/config', { method: 'PUT', body: JSON.stringify({ provider: 'custom', custom: { ...custom, auto_upload: next, secret_key: originalSecretSaved ? '***' : '' }, }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err?.error || 'Failed to update auto-upload'); } } catch (err) { toast.error((err as Error)?.message || 'Failed to update auto-upload.'); setCustom(c => ({ ...c, auto_upload: !next })); } }; const confirmDelete = async () => { if (!deleteKey) return; try { const encoded = btoa(deleteKey).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const res = await apiFetch(`/cloud-backup/object/${encoded}`, { method: 'DELETE' }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err?.error || 'Failed to delete cloud snapshot'); } toast.success('Cloud snapshot deleted.'); loadSnapshots(); if (provider === 'sencho') loadUsage(); } catch (err) { toast.error((err as Error)?.message || 'Failed to delete cloud snapshot.'); } finally { setDeleteKey(null); } }; useMastheadStats( loading ? null : [ { label: 'PROVIDER', value: provider, tone: provider === 'disabled' ? 'subtitle' : 'value', }, ...(provider === 'sencho' && usage ? [{ label: 'USED', value: `${formatBytes(usage.used_bytes)} / ${formatBytes(usage.quota_bytes)}`, }] : []), ...(snapshots.length > 0 ? [{ label: 'SNAPSHOTS', value: `${snapshots.length}` }] : []), ], ); if (loading) { return (
); } const usagePercent = usage && usage.quota_bytes > 0 ? Math.min(100, Math.round((usage.used_bytes / usage.quota_bytes) * 100)) : 0; const usageColor = usagePercent >= 90 ? 'var(--destructive)' : usagePercent >= 80 ? 'var(--warning)' : 'var(--brand)'; return (

Choose where fleet snapshots are replicated.

{isPaid && provider === 'sencho' && !senchoProvisioned && (
Activate Sencho Cloud Backup

Activates a 500 MB allowance backed by Cloudflare R2, scoped to this Admiral license.

{provisioning ? : } Activate
)} {isPaid && provider === 'sencho' && senchoProvisioned && (
Sencho Cloud Backup
{usage && (
Storage used {formatBytes(usage.used_bytes)} / {formatBytes(usage.quota_bytes)} ({usage.object_count} objects)
)}

Auto-upload is on for Sencho Cloud Backup. Every fleet snapshot is replicated within seconds.

)} {provider === 'custom' && (
Custom S3 Configuration
{saving ? : null} Save
setCustom({ ...custom, endpoint: e.target.value })} />
setCustom({ ...custom, region: e.target.value })} />
setCustom({ ...custom, bucket: e.target.value })} />
setCustom({ ...custom, path_prefix: e.target.value })} />
setCustom({ ...custom, access_key: e.target.value })} />
setCustom({ ...custom, secret_key: e.target.value })} />

Automatically upload every fleet snapshot to this bucket.

)} {provider !== 'disabled' && (
Cloud Snapshots
{needsPagination && ( <> {safePage + 1} / {totalPages} )}
{snapshots.length === 0 ? (
No cloud snapshots yet. The next fleet snapshot will appear here.
) : (
    {pagedSnapshots.map(s => (
  • {s.objectKey.split('/').pop()}
    {formatBytes(s.sizeBytes)} {s.lastModified ? `· ${new Date(s.lastModified).toLocaleString()}` : ''}
    Download Delete
  • ))}
)}
)} !open && setDeleteKey(null)} variant="destructive" kicker="CLOUD · DELETE · IRREVERSIBLE" title="Delete cloud snapshot" confirmLabel="Delete" onConfirm={confirmDelete} >

Permanently removes the archive from your bucket. The local SQLite copy is unaffected.

); }