diff --git a/scripts/loc_hygiene_waivers.tsv b/scripts/loc_hygiene_waivers.tsv index 8941b3e..428107e 100644 --- a/scripts/loc_hygiene_waivers.tsv +++ b/scripts/loc_hygiene_waivers.tsv @@ -1,4 +1,3 @@ # relative_path max_lines reason internal/k8s/jobs.go 670 legacy-oversize internal/server/server.go 2203 legacy-oversize -web/src/App.tsx 918 legacy-oversize diff --git a/web/src/App.tsx b/web/src/App.tsx index 31a62f8..5c0eb14 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,286 +1,62 @@ import { useEffect, useMemo, useState } from 'react'; +import { B2UsagePanel } from './components/B2UsagePanel'; +import { BackupPoliciesPanel } from './components/BackupPoliciesPanel'; +import { PVCInventoryPanel } from './components/PVCInventoryPanel'; +import { RestorePlannerPanel } from './components/RestorePlannerPanel'; +import { + type AuthInfo, + type BackupListResponse, + type BackupPolicy, + type BackupPolicyListResponse, + type B2UsageResponse, + EMPTY_B2, + type InventoryResponse, + type RestoreSelection +} from './soteria-types'; +import { + delay, + fetchJSON, + suggestNamespacePrefix, + suggestTargetPVCName +} from './soteria-ui-helpers'; import './styles.css'; -interface AuthInfo { - authenticated: boolean; - user?: string; - email?: string; - groups?: string[]; - allowed_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; - dedupe?: boolean; - keep_last?: number; - 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: [] -}; - -function looksLikeHTML(value: string): boolean { - const sample = value.trim().slice(0, 512).toLowerCase(); - return sample.startsWith('\s*([^<]+)\s*<\/title>/i); - return match?.[1]?.trim() || ''; -} - -function extractRequestID(value: string): string { - const match = value.match(/Request ID:\s*([0-9a-f-]+)/i); - return match?.[1]?.trim() || ''; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => { - window.setTimeout(resolve, ms); - }); -} - -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) { - let message = typeof payload === 'object' && payload !== null && 'error' in payload - ? String((payload as { error: unknown }).error) - : `${response.status} ${response.statusText}`; - if (looksLikeHTML(text)) { - const title = extractHTMLTitle(text); - const requestID = extractRequestID(text); - message = `upstream gateway error (${response.status}${title ? ` ${title}` : ''})`; - if (requestID) { - message = `${message}; request id ${requestID}`; - } - } - throw new Error(message); - } - return payload as T; -} - -function formatBytes(value?: number): string { - if (value === undefined || value === null || Number.isNaN(value)) { - return 'n/a'; - } - if (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 [authError, setAuthError] = useState(''); const [inventory, setInventory] = useState(null); - const [inventoryError, setInventoryError] = useState(''); - + const [inventoryError, setInventoryError] = useState(''); const [policies, setPolicies] = useState([]); - const [policyError, setPolicyError] = useState(''); - + const [policyError, setPolicyError] = useState(''); const [b2Usage, setB2Usage] = useState(EMPTY_B2); - const [b2Error, setB2Error] = useState(''); - const [b2Refreshing, setB2Refreshing] = useState(false); - + 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 [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 [policyDedupe, setPolicyDedupe] = useState(true); - const [policyKeepLast, setPolicyKeepLast] = useState(0); - const [manualDedupe, setManualDedupe] = useState(true); - const [manualKeepLast, setManualKeepLast] = useState(0); + const [policyNamespace, setPolicyNamespace] = useState(''); + const [policyPVC, setPolicyPVC] = useState(''); + const [policyIntervalHours, setPolicyIntervalHours] = useState(24); + const [policyEnabled, setPolicyEnabled] = useState(true); + const [policyDedupe, setPolicyDedupe] = useState(true); + const [policyKeepLast, setPolicyKeepLast] = useState(0); + const [manualDedupe, setManualDedupe] = useState(true); + const [manualKeepLast, setManualKeepLast] = useState(0); + + const [lastAction, setLastAction] = useState('No action yet.'); + const [busy, setBusy] = useState(false); - 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) { @@ -290,12 +66,10 @@ function App() { return total; }, [inventory]); - const namespaceOptions = useMemo(() => { - if (!inventory) { - return [] as string[]; - } - return inventory.namespaces.map((item) => item.name); - }, [inventory]); + const namespaceOptions = useMemo( + () => inventory?.namespaces.map((item) => item.name) ?? [], + [inventory] + ); const writeAction = (payload: unknown): void => { if (typeof payload === 'string') { @@ -317,7 +91,8 @@ function App() { }; const loadInventory = async (): Promise => { - const fetchInventory = async (): Promise => fetchJSON('/v1/inventory'); + const fetchInventory = async (): Promise => fetchJSON('/v1/inventory'); + try { const payload = await fetchInventory(); setInventory(payload); @@ -327,6 +102,8 @@ function App() { } } catch (error) { let message = error instanceof Error ? error.message : 'failed to load inventory'; + + // The API occasionally returns a transient gateway HTML page while upstream warms back up. if (message.includes('upstream gateway error')) { try { await delay(1000); @@ -341,6 +118,7 @@ function App() { message = retryError instanceof Error ? retryError.message : message; } } + setInventoryError(message); } }; @@ -358,7 +136,8 @@ function App() { const loadB2Usage = async (forceRefresh = false): Promise => { try { - const payload = await fetchJSON(forceRefresh ? '/v1/b2?refresh=1' : '/v1/b2'); + const endpoint = forceRefresh ? '/v1/b2?refresh=1' : '/v1/b2'; + const payload = await fetchJSON(endpoint); setB2Usage(payload); setB2Error(''); } catch (error) { @@ -393,9 +172,11 @@ function App() { if (activeBackupCount <= 0) { return undefined; } + const handle = window.setInterval(() => { void loadInventory(); }, 8000); + return () => { window.clearInterval(handle); }; @@ -407,12 +188,22 @@ function App() { const payload = await fetchJSON('/v1/backup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ namespace, pvc, dry_run: false, dedupe: manualDedupe, keep_last: manualKeepLast }) + body: JSON.stringify({ + namespace, + pvc, + dry_run: false, + dedupe: manualDedupe, + keep_last: manualKeepLast + }) }); writeAction(payload); await Promise.all([loadInventory(), loadB2Usage()]); } catch (error) { - writeAction({ error: error instanceof Error ? error.message : 'backup request failed', namespace, pvc }); + writeAction({ + error: error instanceof Error ? error.message : 'backup request failed', + namespace, + pvc + }); } finally { setBusy(false); } @@ -424,12 +215,20 @@ function App() { const payload = await fetchJSON('/v1/backup/namespace', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ namespace, dry_run: false, dedupe: manualDedupe, keep_last: manualKeepLast }) + body: JSON.stringify({ + namespace, + dry_run: false, + dedupe: manualDedupe, + keep_last: manualKeepLast + }) }); writeAction(payload); await Promise.all([loadInventory(), loadB2Usage()]); } catch (error) { - writeAction({ error: error instanceof Error ? error.message : 'namespace backup failed', namespace }); + writeAction({ + error: error instanceof Error ? error.message : 'namespace backup failed', + namespace + }); } finally { setBusy(false); } @@ -438,7 +237,9 @@ function App() { const openPVCSelection = async (namespace: string, pvc: string): Promise => { setBusy(true); try { - const payload = await fetchJSON(`/v1/backups?namespace=${encodeURIComponent(namespace)}&pvc=${encodeURIComponent(pvc)}`); + 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); @@ -446,7 +247,11 @@ function App() { 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 }); + writeAction({ + error: error instanceof Error ? error.message : 'failed to load backups', + namespace, + pvc + }); } finally { setBusy(false); } @@ -464,6 +269,7 @@ function App() { if (selection.kind !== 'pvc') { return; } + setBusy(true); try { const payload = await fetchJSON('/v1/restores', { @@ -498,6 +304,7 @@ function App() { if (selection.kind !== 'namespace') { return; } + setBusy(true); try { const payload = await fetchJSON('/v1/restores/namespace', { @@ -559,25 +366,40 @@ function App() { writeAction(payload); await Promise.all([loadPolicies(), loadInventory()]); } catch (error) { - writeAction({ error: error instanceof Error ? error.message : 'policy delete failed', policy_id: policyID }); + writeAction({ + error: error instanceof Error ? error.message : 'policy delete failed', + policy_id: policyID + }); } finally { setBusy(false); } }; + const loadPolicyIntoForm = (policy: BackupPolicy): void => { + setPolicyNamespace(policy.namespace); + setPolicyPVC(policy.pvc || ''); + setPolicyIntervalHours(policy.interval_hours); + setPolicyEnabled(policy.enabled); + setPolicyDedupe(policy.dedupe !== false); + setPolicyKeepLast(policy.keep_last ?? 0); + }; + const authLabel = auth ? `${auth.user || auth.email || 'authenticated'} | groups: ${(auth.groups || []).join(', ') || 'none'}` : authError || 'anonymous'; - const allowedGroupLabel = auth?.allowed_groups && auth.allowed_groups.length > 0 - ? `Access requires: ${auth.allowed_groups.join(', ')}` - : 'Access requires: any authenticated user'; + const allowedGroupLabel = + auth?.allowed_groups && auth.allowed_groups.length > 0 + ? `Access requires: ${auth.allowed_groups.join(', ')}` + : 'Access requires: any authenticated user'; return (

Soteria Backup Console

-

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

+

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

{authLabel} @@ -594,237 +416,46 @@ function App() {
-
-
-

PVC Inventory

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

This setting applies to both `Backup now` and `Backup namespace` actions.

- {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; - const latestSizeLabel = formatBytes(pvc.last_backup_size_bytes); - const totalStoredLabel = formatBytes(pvc.total_backup_size_bytes); - const showResticSizeHint = pvc.driver === 'restic' - && (pvc.last_backup_size_bytes === undefined || pvc.total_backup_size_bytes === undefined); - - 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: {latestSizeLabel} | Total stored: {totalStoredLabel} -

- {showResticSizeHint && ( -

Per-PVC storage is estimated from restic upload summaries persisted by Soteria. Older backups created before tracking may show n/a until a new backup runs.

- )} - {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}

} -
- - -
-
- ); - })} -
-
- ))} -
+
-
-
-

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) => ( - - - - - - - ))} - -
BucketObjectsStoredRecent 24h
-
{bucket.name}
-
Last object: {formatTimestamp(bucket.last_modified_at)}
-
{bucket.object_count}{formatBytes(bucket.total_bytes)}{formatBytes(bucket.recent_bytes_24h)}
-
-
- )} -
- -
-

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}
@@ -832,83 +463,27 @@ function App() {
-
-

Backup Policies

-

Policy backups create new restic snapshots. `Keep last` controls version retention per PVC: 1 means only newest copy remains after each run. With dedupe on, unchanged blocks are reused in the shared repository. With dedupe off, Soteria isolates each PVC to its own repository path.

-
- - - - - - - -
- - {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 | Dedupe: {policy.dedupe === false ? 'off' : 'on'} | Keep last: {policy.keep_last ?? 0} | Updated {formatTimestamp(policy.updated_at || policy.created_at)}

-
- - -
-
- ))} -
-
+
diff --git a/web/src/components/B2UsagePanel.tsx b/web/src/components/B2UsagePanel.tsx new file mode 100644 index 0000000..3eebcfa --- /dev/null +++ b/web/src/components/B2UsagePanel.tsx @@ -0,0 +1,78 @@ +import type { B2UsageResponse } from '../soteria-types'; +import { formatBytes, formatTimestamp } from '../soteria-ui-helpers'; + +interface B2UsagePanelProps { + b2Usage: B2UsageResponse; + b2Error: string; + b2Refreshing: boolean; + onRefresh: () => void | Promise; +} + +export function B2UsagePanel({ b2Usage, b2Error, b2Refreshing, onRefresh }: B2UsagePanelProps) { + return ( +
+
+

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) => ( + + + + + + + ))} + +
BucketObjectsStoredRecent 24h
+
{bucket.name}
+
Last object: {formatTimestamp(bucket.last_modified_at)}
+
{bucket.object_count}{formatBytes(bucket.total_bytes)}{formatBytes(bucket.recent_bytes_24h)}
+
+
+ )} +
+ ); +} diff --git a/web/src/components/BackupPoliciesPanel.tsx b/web/src/components/BackupPoliciesPanel.tsx new file mode 100644 index 0000000..85c0ff7 --- /dev/null +++ b/web/src/components/BackupPoliciesPanel.tsx @@ -0,0 +1,122 @@ +import type { BackupPolicy } from '../soteria-types'; +import { formatTimestamp } from '../soteria-ui-helpers'; + +interface BackupPoliciesPanelProps { + policies: BackupPolicy[]; + policyError: string; + namespaceOptions: string[]; + policyNamespace: string; + policyPVC: string; + policyIntervalHours: number; + policyEnabled: boolean; + policyDedupe: boolean; + policyKeepLast: number; + busy: boolean; + onPolicyNamespaceChange: (value: string) => void; + onPolicyPVCChange: (value: string) => void; + onPolicyIntervalHoursChange: (value: number) => void; + onPolicyEnabledChange: (value: boolean) => void; + onPolicyDedupeChange: (value: boolean) => void; + onPolicyKeepLastChange: (value: number) => void; + onSavePolicy: () => void | Promise; + onDeletePolicy: (policyID: string) => void | Promise; + onLoadPolicy: (policy: BackupPolicy) => void; +} + +export function BackupPoliciesPanel({ + policies, + policyError, + namespaceOptions, + policyNamespace, + policyPVC, + policyIntervalHours, + policyEnabled, + policyDedupe, + policyKeepLast, + busy, + onPolicyNamespaceChange, + onPolicyPVCChange, + onPolicyIntervalHoursChange, + onPolicyEnabledChange, + onPolicyDedupeChange, + onPolicyKeepLastChange, + onSavePolicy, + onDeletePolicy, + onLoadPolicy +}: BackupPoliciesPanelProps) { + return ( +
+

Backup Policies

+

+ Policy backups create new restic snapshots. `Keep last` controls version retention per PVC: 1 means only newest copy + remains after each run. With dedupe on, unchanged blocks are reused in the shared repository. With dedupe off, + Soteria isolates each PVC to its own repository path. +

+
+ + + + + + + +
+ + {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 | Dedupe: {policy.dedupe === false ? 'off' : 'on'} | Keep last: {policy.keep_last ?? 0} + {' '}| Updated {formatTimestamp(policy.updated_at || policy.created_at)} +

+
+ + +
+
+ ))} +
+
+ ); +} diff --git a/web/src/components/PVCInventoryPanel.tsx b/web/src/components/PVCInventoryPanel.tsx new file mode 100644 index 0000000..ce9335e --- /dev/null +++ b/web/src/components/PVCInventoryPanel.tsx @@ -0,0 +1,136 @@ +import type { InventoryResponse } from '../soteria-types'; +import { formatBytes, formatLabel, formatTimestamp, progressChipClass } from '../soteria-ui-helpers'; + +interface PVCInventoryPanelProps { + inventory: InventoryResponse | null; + inventoryError: string; + manualDedupe: boolean; + manualKeepLast: number; + busy: boolean; + onManualDedupeChange: (value: boolean) => void; + onManualKeepLastChange: (value: number) => void; + onTriggerNamespaceBackup: (namespace: string) => void | Promise; + onOpenNamespaceSelection: (namespace: string) => void; + onTriggerBackup: (namespace: string, pvc: string) => void | Promise; + onOpenPVCSelection: (namespace: string, pvc: string) => void | Promise; +} + +export function PVCInventoryPanel({ + inventory, + inventoryError, + manualDedupe, + manualKeepLast, + busy, + onManualDedupeChange, + onManualKeepLastChange, + onTriggerNamespaceBackup, + onOpenNamespaceSelection, + onTriggerBackup, + onOpenPVCSelection +}: PVCInventoryPanelProps) { + return ( +
+
+

PVC Inventory

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

This setting applies to both `Backup now` and `Backup namespace` actions.

+ {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; + const latestSizeLabel = formatBytes(pvc.last_backup_size_bytes); + const totalStoredLabel = formatBytes(pvc.total_backup_size_bytes); + const showResticSizeHint = pvc.driver === 'restic' + && (pvc.last_backup_size_bytes === undefined || pvc.total_backup_size_bytes === undefined); + + 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: {latestSizeLabel} | Total stored: {totalStoredLabel} +

+ {showResticSizeHint && ( +

+ Per-PVC storage is estimated from restic upload summaries persisted by Soteria. Older backups created + before tracking may show n/a until a new backup runs. +

+ )} + {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}

} +
+ + +
+
+ ); + })} +
+
+ ))} +
+ ); +} diff --git a/web/src/components/RestorePlannerPanel.tsx b/web/src/components/RestorePlannerPanel.tsx new file mode 100644 index 0000000..ceefd14 --- /dev/null +++ b/web/src/components/RestorePlannerPanel.tsx @@ -0,0 +1,116 @@ +import type { RestoreSelection } from '../soteria-types'; + +interface RestorePlannerPanelProps { + selection: RestoreSelection; + namespaceOptions: string[]; + restoreNamespace: string; + restorePVC: string; + restoreBackupURL: string; + namespaceRestoreTarget: string; + namespaceRestorePrefix: string; + namespaceRestoreSnapshot: string; + busy: boolean; + onRestoreNamespaceChange: (value: string) => void; + onRestorePVCChange: (value: string) => void; + onRestoreBackupURLChange: (value: string) => void; + onNamespaceRestoreTargetChange: (value: string) => void; + onNamespaceRestorePrefixChange: (value: string) => void; + onNamespaceRestoreSnapshotChange: (value: string) => void; + onRunPVCRestore: (dryRun: boolean) => void | Promise; + onRunNamespaceRestore: (dryRun: boolean) => void | Promise; +} + +export function RestorePlannerPanel({ + selection, + namespaceOptions, + restoreNamespace, + restorePVC, + restoreBackupURL, + namespaceRestoreTarget, + namespaceRestorePrefix, + namespaceRestoreSnapshot, + busy, + onRestoreNamespaceChange, + onRestorePVCChange, + onRestoreBackupURLChange, + onNamespaceRestoreTargetChange, + onNamespaceRestorePrefixChange, + onNamespaceRestoreSnapshotChange, + onRunPVCRestore, + onRunNamespaceRestore +}: RestorePlannerPanelProps) { + return ( +
+

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}

+ + + +
+ + +
+
+ )} +
+ ); +} diff --git a/web/src/soteria-types.ts b/web/src/soteria-types.ts new file mode 100644 index 0000000..0356592 --- /dev/null +++ b/web/src/soteria-types.ts @@ -0,0 +1,114 @@ +export interface AuthInfo { + authenticated: boolean; + user?: string; + email?: string; + groups?: string[]; + allowed_groups?: string[]; +} + +export interface BackupRecord { + name: string; + snapshot_name?: string; + created?: string; + state?: string; + url?: string; + size?: string; + latest?: boolean; +} + +export interface BackupListResponse { + namespace: string; + pvc: string; + volume: string; + backups: BackupRecord[]; +} + +export 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; +} + +export interface NamespaceInventory { + name: string; + pvcs: PVCInventory[]; +} + +export interface InventoryResponse { + generated_at: string; + namespaces: NamespaceInventory[]; +} + +export interface BackupPolicy { + id: string; + namespace: string; + pvc?: string; + interval_hours: number; + enabled: boolean; + dedupe?: boolean; + keep_last?: number; + created_at?: string; + updated_at?: string; +} + +export interface BackupPolicyListResponse { + policies: BackupPolicy[]; +} + +export interface B2BucketUsage { + name: string; + object_count: number; + total_bytes: number; + recent_objects_24h: number; + recent_bytes_24h: number; + last_modified_at?: string; +} + +export 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; +} + +export type RestoreSelection = + | { kind: 'none' } + | { kind: 'pvc'; namespace: string; pvc: string; volume: string; backups: BackupRecord[] } + | { kind: 'namespace'; namespace: string }; + +export const EMPTY_B2: B2UsageResponse = { + enabled: false, + available: false, + total_objects: 0, + total_bytes: 0, + recent_objects_24h: 0, + recent_bytes_24h: 0, + buckets: [] +}; diff --git a/web/src/soteria-ui-helpers.ts b/web/src/soteria-ui-helpers.ts new file mode 100644 index 0000000..86129a8 --- /dev/null +++ b/web/src/soteria-ui-helpers.ts @@ -0,0 +1,126 @@ +export function delay(ms: number): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + +function looksLikeHTML(value: string): boolean { + const sample = value.trim().slice(0, 512).toLowerCase(); + return sample.startsWith('\s*([^<]+)\s*<\/title>/i); + return match?.[1]?.trim() || ''; +} + +function extractRequestID(value: string): string { + const match = value.match(/Request ID:\s*([0-9a-f-]+)/i); + return match?.[1]?.trim() || ''; +} + +export 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) { + let message = typeof payload === 'object' && payload !== null && 'error' in payload + ? String((payload as { error: unknown }).error) + : `${response.status} ${response.statusText}`; + if (looksLikeHTML(text)) { + const title = extractHTMLTitle(text); + const requestID = extractRequestID(text); + message = `upstream gateway error (${response.status}${title ? ` ${title}` : ''})`; + if (requestID) { + message = `${message}; request id ${requestID}`; + } + } + throw new Error(message); + } + return payload as T; +} + +export function formatBytes(value?: number): string { + if (value === undefined || value === null || Number.isNaN(value)) { + return 'n/a'; + } + if (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]}`; +} + +export 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(); +} + +export function formatLabel(value?: string): string { + if (!value) { + return 'Needs attention'; + } + return value.replace(/_/g, ' '); +} + +export function progressChipClass(state?: string): 'good' | 'warn' | 'bad' { + switch ((state || '').toLowerCase()) { + case 'completed': + return 'good'; + case 'failed': + return 'bad'; + default: + return 'warn'; + } +} + +export 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, ''); +} + +export 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}-`; +}