import { useEffect, useMemo, useState } from 'react'; import './styles.css'; interface AuthInfo { authenticated: boolean; user?: string; email?: string; groups?: string[]; } interface BackupRecord { name: string; snapshot_name?: string; created?: string; state?: string; url?: string; size?: string; latest?: boolean; } interface BackupListResponse { namespace: string; pvc: string; volume: string; backups: BackupRecord[]; } interface PVCInventory { namespace: string; pvc: string; volume?: string; phase?: string; storage_class?: string; capacity?: string; access_modes?: string[]; driver?: string; last_backup_at?: string; last_backup_age_hours?: number; backup_count: number; completed_backups: number; active_backups: number; last_job_name?: string; last_job_state?: string; last_job_started_at?: string; last_job_progress_pct: number; last_backup_size_bytes?: number; total_backup_size_bytes?: number; healthy: boolean; health_reason?: string; error?: string; } interface NamespaceInventory { name: string; pvcs: PVCInventory[]; } interface InventoryResponse { generated_at: string; namespaces: NamespaceInventory[]; } interface BackupPolicy { id: string; namespace: string; pvc?: string; interval_hours: number; enabled: boolean; created_at?: string; updated_at?: string; } interface BackupPolicyListResponse { policies: BackupPolicy[]; } interface B2BucketUsage { name: string; object_count: number; total_bytes: number; recent_objects_24h: number; recent_bytes_24h: number; last_modified_at?: string; } interface B2UsageResponse { enabled: boolean; available: boolean; endpoint?: string; region?: string; scanned_at?: string; scan_duration_ms?: number; total_objects: number; total_bytes: number; recent_objects_24h: number; recent_bytes_24h: number; buckets?: B2BucketUsage[]; error?: string; } type RestoreSelection = | { kind: 'none' } | { kind: 'pvc'; namespace: string; pvc: string; volume: string; backups: BackupRecord[] } | { kind: 'namespace'; namespace: string }; const EMPTY_B2: B2UsageResponse = { enabled: false, available: false, total_objects: 0, total_bytes: 0, recent_objects_24h: 0, recent_bytes_24h: 0, buckets: [] }; async function fetchJSON(input: string, init?: RequestInit): Promise { const response = await fetch(input, init); const text = await response.text(); let payload: unknown = {}; if (text.trim() !== '') { try { payload = JSON.parse(text); } catch { payload = { error: text }; } } if (!response.ok) { const message = typeof payload === 'object' && payload !== null && 'error' in payload ? String((payload as { error: unknown }).error) : `${response.status} ${response.statusText}`; throw new Error(message); } return payload as T; } function formatBytes(value?: number): string { if (!value || value <= 0) { return '0 B'; } const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; let size = value; let unit = 0; while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; } return `${size.toFixed(unit === 0 ? 0 : 2)} ${units[unit]}`; } function formatTimestamp(value?: string): string { if (!value) { return 'n/a'; } const parsed = new Date(value); if (Number.isNaN(parsed.valueOf())) { return value; } return parsed.toLocaleString(); } function formatLabel(value?: string): string { if (!value) { return 'Needs attention'; } return value.replace(/_/g, ' '); } function progressChipClass(state?: string): 'good' | 'warn' | 'bad' { switch ((state || '').toLowerCase()) { case 'completed': return 'good'; case 'failed': return 'bad'; default: return 'warn'; } } function suggestTargetPVCName(sourcePVC: string): string { const now = new Date(); const pad = (item: number) => String(item).padStart(2, '0'); const stamp = [ now.getUTCFullYear(), pad(now.getUTCMonth() + 1), pad(now.getUTCDate()), pad(now.getUTCHours()), pad(now.getUTCMinutes()) ].join(''); return (`restore-${sourcePVC}-${stamp}`) .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .slice(0, 63) .replace(/-+$/g, ''); } function suggestNamespacePrefix(): string { const now = new Date(); const pad = (item: number) => String(item).padStart(2, '0'); const stamp = [ now.getUTCFullYear(), pad(now.getUTCMonth() + 1), pad(now.getUTCDate()), pad(now.getUTCHours()), pad(now.getUTCMinutes()) ].join(''); return `restore-${stamp}-`; } function App() { const [auth, setAuth] = useState(null); const [authError, setAuthError] = useState(''); const [inventory, setInventory] = useState(null); const [inventoryError, setInventoryError] = useState(''); const [policies, setPolicies] = useState([]); const [policyError, setPolicyError] = useState(''); const [b2Usage, setB2Usage] = useState(EMPTY_B2); const [b2Error, setB2Error] = useState(''); const [b2Refreshing, setB2Refreshing] = useState(false); const [selection, setSelection] = useState({ kind: 'none' }); const [restoreNamespace, setRestoreNamespace] = useState(''); const [restorePVC, setRestorePVC] = useState(''); const [restoreBackupURL, setRestoreBackupURL] = useState(''); const [namespaceRestoreTarget, setNamespaceRestoreTarget] = useState(''); const [namespaceRestorePrefix, setNamespaceRestorePrefix] = useState(suggestNamespacePrefix()); const [namespaceRestoreSnapshot, setNamespaceRestoreSnapshot] = useState(''); const [policyNamespace, setPolicyNamespace] = useState(''); const [policyPVC, setPolicyPVC] = useState(''); const [policyIntervalHours, setPolicyIntervalHours] = useState(24); const [policyEnabled, setPolicyEnabled] = useState(true); const [lastAction, setLastAction] = useState('No action yet.'); const [busy, setBusy] = useState(false); const activeBackupCount = useMemo(() => { if (!inventory) { return 0; } let total = 0; for (const namespace of inventory.namespaces) { for (const pvc of namespace.pvcs) { total += pvc.active_backups || 0; } } return total; }, [inventory]); const namespaceOptions = useMemo(() => { if (!inventory) { return [] as string[]; } return inventory.namespaces.map((item) => item.name); }, [inventory]); const writeAction = (payload: unknown): void => { if (typeof payload === 'string') { setLastAction(payload); return; } setLastAction(JSON.stringify(payload, null, 2)); }; const loadWhoAmI = async (): Promise => { try { const who = await fetchJSON('/v1/whoami'); setAuth(who); setAuthError(''); } catch (error) { setAuth(null); setAuthError(error instanceof Error ? error.message : 'failed to load auth'); } }; const loadInventory = async (): Promise => { try { const payload = await fetchJSON('/v1/inventory'); setInventory(payload); setInventoryError(''); if (!policyNamespace && payload.namespaces.length > 0) { setPolicyNamespace(payload.namespaces[0].name); } } catch (error) { setInventory(null); setInventoryError(error instanceof Error ? error.message : 'failed to load inventory'); } }; const loadPolicies = async (): Promise => { try { const payload = await fetchJSON('/v1/policies'); setPolicies(payload.policies || []); setPolicyError(''); } catch (error) { setPolicies([]); setPolicyError(error instanceof Error ? error.message : 'failed to load policies'); } }; const loadB2Usage = async (forceRefresh = false): Promise => { try { const payload = await fetchJSON(forceRefresh ? '/v1/b2?refresh=1' : '/v1/b2'); setB2Usage(payload); setB2Error(''); } catch (error) { setB2Usage(EMPTY_B2); setB2Error(error instanceof Error ? error.message : 'failed to load B2 usage'); } }; const refreshB2Usage = async (): Promise => { setB2Refreshing(true); try { await loadB2Usage(true); } finally { setB2Refreshing(false); } }; const refreshAll = async (): Promise => { setBusy(true); try { await Promise.all([loadWhoAmI(), loadInventory(), loadPolicies(), loadB2Usage()]); } finally { setBusy(false); } }; useEffect(() => { void refreshAll(); }, []); useEffect(() => { if (activeBackupCount <= 0) { return undefined; } const handle = window.setInterval(() => { void loadInventory(); }, 8000); return () => { window.clearInterval(handle); }; }, [activeBackupCount]); const triggerBackup = async (namespace: string, pvc: string): Promise => { setBusy(true); try { const payload = await fetchJSON('/v1/backup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ namespace, pvc, dry_run: false }) }); writeAction(payload); await Promise.all([loadInventory(), loadB2Usage()]); } catch (error) { writeAction({ error: error instanceof Error ? error.message : 'backup request failed', namespace, pvc }); } finally { setBusy(false); } }; const triggerNamespaceBackup = async (namespace: string): Promise => { setBusy(true); try { const payload = await fetchJSON('/v1/backup/namespace', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ namespace, dry_run: false }) }); writeAction(payload); await Promise.all([loadInventory(), loadB2Usage()]); } catch (error) { writeAction({ error: error instanceof Error ? error.message : 'namespace backup failed', namespace }); } finally { setBusy(false); } }; const openPVCSelection = async (namespace: string, pvc: string): Promise => { setBusy(true); try { const payload = await fetchJSON(`/v1/backups?namespace=${encodeURIComponent(namespace)}&pvc=${encodeURIComponent(pvc)}`); const completed = payload.backups.filter((item) => item.state === 'Completed' && item.url); setSelection({ kind: 'pvc', namespace, pvc, volume: payload.volume, backups: completed }); setRestoreNamespace(namespace); setRestorePVC(suggestTargetPVCName(pvc)); setRestoreBackupURL(completed.length > 0 ? String(completed[0].url) : ''); writeAction(payload); } catch (error) { writeAction({ error: error instanceof Error ? error.message : 'failed to load backups', namespace, pvc }); } finally { setBusy(false); } }; const openNamespaceSelection = (namespace: string): void => { setSelection({ kind: 'namespace', namespace }); setNamespaceRestoreTarget(namespace); setNamespaceRestorePrefix(suggestNamespacePrefix()); setNamespaceRestoreSnapshot(''); writeAction(`Namespace restore planner loaded for ${namespace}.`); }; const runPVCRestore = async (dryRun: boolean): Promise => { if (selection.kind !== 'pvc') { return; } setBusy(true); try { const payload = await fetchJSON('/v1/restores', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ namespace: selection.namespace, pvc: selection.pvc, backup_url: restoreBackupURL, target_namespace: restoreNamespace, target_pvc: restorePVC, dry_run: dryRun }) }); writeAction(payload); await Promise.all([loadInventory(), loadB2Usage()]); } catch (error) { writeAction({ error: error instanceof Error ? error.message : 'restore failed', namespace: selection.namespace, pvc: selection.pvc, target_namespace: restoreNamespace, target_pvc: restorePVC, dry_run: dryRun }); } finally { setBusy(false); } }; const runNamespaceRestore = async (dryRun: boolean): Promise => { if (selection.kind !== 'namespace') { return; } setBusy(true); try { const payload = await fetchJSON('/v1/restores/namespace', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ namespace: selection.namespace, target_namespace: namespaceRestoreTarget, target_prefix: namespaceRestorePrefix, snapshot: namespaceRestoreSnapshot, dry_run: dryRun }) }); writeAction(payload); await Promise.all([loadInventory(), loadB2Usage()]); } catch (error) { writeAction({ error: error instanceof Error ? error.message : 'namespace restore failed', namespace: selection.namespace, target_namespace: namespaceRestoreTarget, target_prefix: namespaceRestorePrefix, dry_run: dryRun }); } finally { setBusy(false); } }; const savePolicy = async (): Promise => { setBusy(true); try { const payload = await fetchJSON('/v1/policies', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ namespace: policyNamespace, pvc: policyPVC, interval_hours: policyIntervalHours, enabled: policyEnabled }) }); writeAction(payload); await Promise.all([loadPolicies(), loadInventory()]); } catch (error) { writeAction({ error: error instanceof Error ? error.message : 'policy save failed' }); } finally { setBusy(false); } }; const deletePolicy = async (policyID: string): Promise => { setBusy(true); try { const payload = await fetchJSON(`/v1/policies/${encodeURIComponent(policyID)}`, { method: 'DELETE' }); writeAction(payload); await Promise.all([loadPolicies(), loadInventory()]); } catch (error) { writeAction({ error: error instanceof Error ? error.message : 'policy delete failed', policy_id: policyID }); } finally { setBusy(false); } }; const authLabel = auth ? `${auth.user || auth.email || 'authenticated'} (${(auth.groups || []).join(', ') || 'no groups'})` : authError || 'anonymous'; return (

Soteria Backup Console

Dark-mode React UI for backup drills, policy control, and B2 consumption visibility.

{authLabel} {activeBackupCount > 0 && ( {activeBackupCount} backup job{activeBackupCount === 1 ? '' : 's'} active )}

PVC Inventory

{inventory?.generated_at ? `Updated ${formatTimestamp(inventory.generated_at)}` : 'No inventory yet'}
{inventoryError &&

{inventoryError}

} {!inventory && !inventoryError &&

Loading inventory...

} {inventory?.namespaces.map((namespace) => (

{namespace.name}

{namespace.pvcs.map((pvc) => { const healthClass = pvc.healthy ? 'good' : (pvc.health_reason === 'in_progress' ? 'warn' : 'bad'); const healthLabel = pvc.healthy ? 'Healthy' : formatLabel(pvc.health_reason); const progressPct = Math.max(0, Math.min(100, Number(pvc.last_job_progress_pct || 0))); const progressClass = progressChipClass(pvc.last_job_state); const showProgress = Boolean(pvc.last_job_name) || (pvc.active_backups || 0) > 0; return (

{pvc.pvc}

{pvc.volume || 'unknown volume'} | {pvc.storage_class || 'no class'} | {pvc.capacity || 'unknown size'}

{healthLabel}

Last backup: {pvc.last_backup_at ? `${formatTimestamp(pvc.last_backup_at)} (${(pvc.last_backup_age_hours || 0).toFixed(1)}h ago)` : 'never'}

Backups: {pvc.completed_backups}/{pvc.backup_count} completed | Latest size: {formatBytes(pvc.last_backup_size_bytes)} | Total stored: {formatBytes(pvc.total_backup_size_bytes)}

{showProgress && (

Job: {pvc.last_job_name || 'n/a'} {pvc.last_job_started_at ? ` | Started ${formatTimestamp(pvc.last_job_started_at)}` : ''}

{pvc.last_job_state || 'Unknown'}
0 ? 'active' : ''}`} style={{ width: `${progressPct}%` }} />

Progress {progressPct}% | Active jobs: {pvc.active_backups || 0}

)} {pvc.error &&

{pvc.error}

}
); })}
))}

Restore Planner

Inventory restore buttons preload this panel. This is where restore dry-runs and execution happen.

{selection.kind === 'none' &&

Choose Restore on a PVC or namespace to begin.

} {selection.kind === 'pvc' && (

Source: {selection.namespace}/{selection.pvc} ({selection.volume})

)} {selection.kind === 'namespace' && (

Source namespace: {selection.namespace}

)}

Last Action

{lastAction}

B2 Consumption

{b2Error &&

{b2Error}

} {!b2Error && !b2Usage.enabled &&

B2 monitoring is disabled in Soteria config.

} {!b2Error && b2Usage.enabled && !b2Usage.available &&

{b2Usage.error || 'B2 usage currently unavailable.'}

} {b2Usage.enabled && (

Endpoint: {b2Usage.endpoint || 'n/a'} | Region: {b2Usage.region || 'n/a'}

Last scan: {formatTimestamp(b2Usage.scanned_at)} | Duration: {b2Usage.scan_duration_ms || 0}ms

Stored bytes {formatBytes(b2Usage.total_bytes)}
Objects {b2Usage.total_objects}
Recent bytes (24h) {formatBytes(b2Usage.recent_bytes_24h)}
Recent objects (24h) {b2Usage.recent_objects_24h}

Recent 24h values are object-change bandwidth proxy from bucket scans. B2 egress billing totals are not exposed by S3 object listing.

{(b2Usage.buckets || []).map((bucket) => ( ))}
Bucket Objects Stored Recent 24h
{bucket.name}
Last object: {formatTimestamp(bucket.last_modified_at)}
{bucket.object_count} {formatBytes(bucket.total_bytes)} {formatBytes(bucket.recent_bytes_24h)}
)}

Backup Policies

{policyError &&

{policyError}

} {!policyError && policies.length === 0 &&

No policies yet.

}
{policies.map((policy) => (
{policy.namespace}/{policy.pvc || '*'} {policy.enabled ? 'Enabled' : 'Disabled'}

Every {policy.interval_hours}h | Updated {formatTimestamp(policy.updated_at || policy.created_at)}

))}
); } export default App;