820 lines
30 KiB
TypeScript
820 lines
30 KiB
TypeScript
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<T>(input: string, init?: RequestInit): Promise<T> {
|
|
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<AuthInfo | null>(null);
|
|
const [authError, setAuthError] = useState<string>('');
|
|
|
|
const [inventory, setInventory] = useState<InventoryResponse | null>(null);
|
|
const [inventoryError, setInventoryError] = useState<string>('');
|
|
|
|
const [policies, setPolicies] = useState<BackupPolicy[]>([]);
|
|
const [policyError, setPolicyError] = useState<string>('');
|
|
|
|
const [b2Usage, setB2Usage] = useState<B2UsageResponse>(EMPTY_B2);
|
|
const [b2Error, setB2Error] = useState<string>('');
|
|
const [b2Refreshing, setB2Refreshing] = useState<boolean>(false);
|
|
|
|
const [selection, setSelection] = useState<RestoreSelection>({ kind: 'none' });
|
|
const [restoreNamespace, setRestoreNamespace] = useState<string>('');
|
|
const [restorePVC, setRestorePVC] = useState<string>('');
|
|
const [restoreBackupURL, setRestoreBackupURL] = useState<string>('');
|
|
|
|
const [namespaceRestoreTarget, setNamespaceRestoreTarget] = useState<string>('');
|
|
const [namespaceRestorePrefix, setNamespaceRestorePrefix] = useState<string>(suggestNamespacePrefix());
|
|
const [namespaceRestoreSnapshot, setNamespaceRestoreSnapshot] = useState<string>('');
|
|
|
|
const [policyNamespace, setPolicyNamespace] = useState<string>('');
|
|
const [policyPVC, setPolicyPVC] = useState<string>('');
|
|
const [policyIntervalHours, setPolicyIntervalHours] = useState<number>(24);
|
|
const [policyEnabled, setPolicyEnabled] = useState<boolean>(true);
|
|
|
|
const [lastAction, setLastAction] = useState<string>('No action yet.');
|
|
const [busy, setBusy] = useState<boolean>(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<void> => {
|
|
try {
|
|
const who = await fetchJSON<AuthInfo>('/v1/whoami');
|
|
setAuth(who);
|
|
setAuthError('');
|
|
} catch (error) {
|
|
setAuth(null);
|
|
setAuthError(error instanceof Error ? error.message : 'failed to load auth');
|
|
}
|
|
};
|
|
|
|
const loadInventory = async (): Promise<void> => {
|
|
try {
|
|
const payload = await fetchJSON<InventoryResponse>('/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<void> => {
|
|
try {
|
|
const payload = await fetchJSON<BackupPolicyListResponse>('/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<void> => {
|
|
try {
|
|
const payload = await fetchJSON<B2UsageResponse>(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<void> => {
|
|
setB2Refreshing(true);
|
|
try {
|
|
await loadB2Usage(true);
|
|
} finally {
|
|
setB2Refreshing(false);
|
|
}
|
|
};
|
|
|
|
const refreshAll = async (): Promise<void> => {
|
|
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<void> => {
|
|
setBusy(true);
|
|
try {
|
|
const payload = await fetchJSON<unknown>('/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<void> => {
|
|
setBusy(true);
|
|
try {
|
|
const payload = await fetchJSON<unknown>('/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<void> => {
|
|
setBusy(true);
|
|
try {
|
|
const payload = await fetchJSON<BackupListResponse>(`/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<void> => {
|
|
if (selection.kind !== 'pvc') {
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
const payload = await fetchJSON<unknown>('/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<void> => {
|
|
if (selection.kind !== 'namespace') {
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
const payload = await fetchJSON<unknown>('/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<void> => {
|
|
setBusy(true);
|
|
try {
|
|
const payload = await fetchJSON<unknown>('/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<void> => {
|
|
setBusy(true);
|
|
try {
|
|
const payload = await fetchJSON<unknown>(`/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 (
|
|
<div className="app-shell">
|
|
<header className="topbar">
|
|
<div>
|
|
<h1>Soteria Backup Console</h1>
|
|
<p className="subtle">Dark-mode React UI for backup drills, policy control, and B2 consumption visibility.</p>
|
|
</div>
|
|
<div className="toolbar">
|
|
<span className={`chip ${auth ? 'good' : 'warn'}`}>{authLabel}</span>
|
|
{activeBackupCount > 0 && (
|
|
<span className="chip warn">
|
|
{activeBackupCount} backup job{activeBackupCount === 1 ? '' : 's'} active
|
|
</span>
|
|
)}
|
|
<button type="button" className="secondary" onClick={() => void refreshAll()} disabled={busy}>
|
|
{busy ? 'Refreshing...' : 'Refresh'}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="layout">
|
|
<section className="panel scroll-panel">
|
|
<div className="panel-header">
|
|
<h2>PVC Inventory</h2>
|
|
<span className="subtle">{inventory?.generated_at ? `Updated ${formatTimestamp(inventory.generated_at)}` : 'No inventory yet'}</span>
|
|
</div>
|
|
{inventoryError && <p className="error">{inventoryError}</p>}
|
|
{!inventory && !inventoryError && <p className="subtle">Loading inventory...</p>}
|
|
{inventory?.namespaces.map((namespace) => (
|
|
<article key={namespace.name} className="namespace-block">
|
|
<div className="namespace-row">
|
|
<h3>{namespace.name}</h3>
|
|
<div className="actions">
|
|
<button type="button" className="secondary" onClick={() => void triggerNamespaceBackup(namespace.name)} disabled={busy}>
|
|
Backup namespace
|
|
</button>
|
|
<button type="button" className="secondary" onClick={() => openNamespaceSelection(namespace.name)}>
|
|
Restore namespace
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="pvc-grid">
|
|
{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 (
|
|
<article key={`${pvc.namespace}/${pvc.pvc}`} className="pvc-card">
|
|
<div className="pvc-title-row">
|
|
<div>
|
|
<h4>{pvc.pvc}</h4>
|
|
<p className="subtle tiny">{pvc.volume || 'unknown volume'} | {pvc.storage_class || 'no class'} | {pvc.capacity || 'unknown size'}</p>
|
|
</div>
|
|
<span className={`chip ${healthClass}`}>
|
|
{healthLabel}
|
|
</span>
|
|
</div>
|
|
<p className="subtle tiny">
|
|
Last backup: {pvc.last_backup_at ? `${formatTimestamp(pvc.last_backup_at)} (${(pvc.last_backup_age_hours || 0).toFixed(1)}h ago)` : 'never'}
|
|
</p>
|
|
<p className="subtle tiny">
|
|
Backups: {pvc.completed_backups}/{pvc.backup_count} completed | Latest size: {formatBytes(pvc.last_backup_size_bytes)} | Total stored: {formatBytes(pvc.total_backup_size_bytes)}
|
|
</p>
|
|
{showProgress && (
|
|
<div className="backup-progress">
|
|
<div className="progress-header">
|
|
<p className="subtle tiny">
|
|
Job: {pvc.last_job_name || 'n/a'}
|
|
{pvc.last_job_started_at ? ` | Started ${formatTimestamp(pvc.last_job_started_at)}` : ''}
|
|
</p>
|
|
<span className={`chip ${progressClass}`}>{pvc.last_job_state || 'Unknown'}</span>
|
|
</div>
|
|
<div className="progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progressPct}>
|
|
<div className={`progress-fill ${progressClass} ${pvc.active_backups > 0 ? 'active' : ''}`} style={{ width: `${progressPct}%` }} />
|
|
</div>
|
|
<p className="subtle tiny">Progress {progressPct}% | Active jobs: {pvc.active_backups || 0}</p>
|
|
</div>
|
|
)}
|
|
{pvc.error && <p className="error tiny">{pvc.error}</p>}
|
|
<div className="actions">
|
|
<button type="button" onClick={() => void triggerBackup(pvc.namespace, pvc.pvc)} disabled={busy}>
|
|
Backup now
|
|
</button>
|
|
<button type="button" className="secondary" onClick={() => void openPVCSelection(pvc.namespace, pvc.pvc)}>
|
|
Restore
|
|
</button>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
</article>
|
|
))}
|
|
</section>
|
|
|
|
<section className="column">
|
|
<section className="panel">
|
|
<h2>Restore Planner</h2>
|
|
<p className="subtle tiny">Inventory restore buttons preload this panel. This is where restore dry-runs and execution happen.</p>
|
|
{selection.kind === 'none' && <p className="subtle">Choose Restore on a PVC or namespace to begin.</p>}
|
|
|
|
{selection.kind === 'pvc' && (
|
|
<div className="stack">
|
|
<p className="subtle tiny"><strong>Source:</strong> {selection.namespace}/{selection.pvc} ({selection.volume})</p>
|
|
<label>
|
|
Backup snapshot
|
|
<select value={restoreBackupURL} onChange={(event) => setRestoreBackupURL(event.target.value)}>
|
|
{selection.backups.length === 0 && <option value="">No completed backups</option>}
|
|
{selection.backups.map((item) => (
|
|
<option key={item.url || item.name} value={item.url || ''}>
|
|
{item.name} | {item.created || 'unknown time'} | {item.size || 'size n/a'}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Target namespace
|
|
<select value={restoreNamespace} onChange={(event) => setRestoreNamespace(event.target.value)}>
|
|
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Target PVC
|
|
<input value={restorePVC} onChange={(event) => setRestorePVC(event.target.value)} />
|
|
</label>
|
|
<div className="actions">
|
|
<button type="button" onClick={() => void runPVCRestore(false)} disabled={busy || !restoreBackupURL}>
|
|
Create restore PVC
|
|
</button>
|
|
<button type="button" className="secondary" onClick={() => void runPVCRestore(true)} disabled={busy || !restoreBackupURL}>
|
|
Dry run
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selection.kind === 'namespace' && (
|
|
<div className="stack">
|
|
<p className="subtle tiny"><strong>Source namespace:</strong> {selection.namespace}</p>
|
|
<label>
|
|
Target namespace
|
|
<select value={namespaceRestoreTarget} onChange={(event) => setNamespaceRestoreTarget(event.target.value)}>
|
|
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Target PVC prefix
|
|
<input value={namespaceRestorePrefix} onChange={(event) => setNamespaceRestorePrefix(event.target.value)} />
|
|
</label>
|
|
<label>
|
|
Snapshot hint (optional)
|
|
<input value={namespaceRestoreSnapshot} onChange={(event) => setNamespaceRestoreSnapshot(event.target.value)} placeholder="blank = latest completed" />
|
|
</label>
|
|
<div className="actions">
|
|
<button type="button" onClick={() => void runNamespaceRestore(false)} disabled={busy}>
|
|
Create restore PVCs
|
|
</button>
|
|
<button type="button" className="secondary" onClick={() => void runNamespaceRestore(true)} disabled={busy}>
|
|
Dry run
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="panel action-panel">
|
|
<h2>Last Action</h2>
|
|
<pre>{lastAction}</pre>
|
|
</section>
|
|
</section>
|
|
|
|
<section className="column">
|
|
<section className="panel">
|
|
<div className="panel-header">
|
|
<h2>B2 Consumption</h2>
|
|
<button type="button" className="secondary" onClick={() => void refreshB2Usage()} disabled={b2Refreshing}>
|
|
{b2Refreshing ? 'Refreshing...' : 'Refresh B2'}
|
|
</button>
|
|
</div>
|
|
{b2Error && <p className="error">{b2Error}</p>}
|
|
{!b2Error && !b2Usage.enabled && <p className="subtle">B2 monitoring is disabled in Soteria config.</p>}
|
|
{!b2Error && b2Usage.enabled && !b2Usage.available && <p className="error">{b2Usage.error || 'B2 usage currently unavailable.'}</p>}
|
|
{b2Usage.enabled && (
|
|
<div className="stack">
|
|
<p className="subtle tiny">Endpoint: {b2Usage.endpoint || 'n/a'} | Region: {b2Usage.region || 'n/a'}</p>
|
|
<p className="subtle tiny">Last scan: {formatTimestamp(b2Usage.scanned_at)} | Duration: {b2Usage.scan_duration_ms || 0}ms</p>
|
|
<div className="stat-grid">
|
|
<div className="stat">
|
|
<span className="label">Stored bytes</span>
|
|
<strong>{formatBytes(b2Usage.total_bytes)}</strong>
|
|
</div>
|
|
<div className="stat">
|
|
<span className="label">Objects</span>
|
|
<strong>{b2Usage.total_objects}</strong>
|
|
</div>
|
|
<div className="stat">
|
|
<span className="label">Recent bytes (24h)</span>
|
|
<strong>{formatBytes(b2Usage.recent_bytes_24h)}</strong>
|
|
</div>
|
|
<div className="stat">
|
|
<span className="label">Recent objects (24h)</span>
|
|
<strong>{b2Usage.recent_objects_24h}</strong>
|
|
</div>
|
|
</div>
|
|
<p className="subtle tiny">Recent 24h values are object-change bandwidth proxy from bucket scans. B2 egress billing totals are not exposed by S3 object listing.</p>
|
|
<div className="bucket-table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Bucket</th>
|
|
<th>Objects</th>
|
|
<th>Stored</th>
|
|
<th>Recent 24h</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(b2Usage.buckets || []).map((bucket) => (
|
|
<tr key={bucket.name}>
|
|
<td>
|
|
<div>{bucket.name}</div>
|
|
<div className="subtle tiny">Last object: {formatTimestamp(bucket.last_modified_at)}</div>
|
|
</td>
|
|
<td>{bucket.object_count}</td>
|
|
<td>{formatBytes(bucket.total_bytes)}</td>
|
|
<td>{formatBytes(bucket.recent_bytes_24h)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="panel scroll-panel">
|
|
<h2>Backup Policies</h2>
|
|
<div className="stack">
|
|
<label>
|
|
Namespace
|
|
<select value={policyNamespace} onChange={(event) => setPolicyNamespace(event.target.value)}>
|
|
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
PVC (optional)
|
|
<input value={policyPVC} onChange={(event) => setPolicyPVC(event.target.value)} placeholder="blank means all PVCs in namespace" />
|
|
</label>
|
|
<label>
|
|
Interval hours
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
value={policyIntervalHours}
|
|
onChange={(event) => setPolicyIntervalHours(Math.max(1, Number(event.target.value || 1)))}
|
|
/>
|
|
</label>
|
|
<label className="checkbox-row">
|
|
<input type="checkbox" checked={policyEnabled} onChange={(event) => setPolicyEnabled(event.target.checked)} />
|
|
Enabled
|
|
</label>
|
|
<button type="button" onClick={() => void savePolicy()} disabled={busy || !policyNamespace}>Save policy</button>
|
|
</div>
|
|
|
|
{policyError && <p className="error">{policyError}</p>}
|
|
{!policyError && policies.length === 0 && <p className="subtle">No policies yet.</p>}
|
|
<div className="policy-list">
|
|
{policies.map((policy) => (
|
|
<article key={policy.id} className="policy-item">
|
|
<div className="policy-head">
|
|
<strong>{policy.namespace}/{policy.pvc || '*'}</strong>
|
|
<span className={`chip ${policy.enabled ? 'good' : 'bad'}`}>{policy.enabled ? 'Enabled' : 'Disabled'}</span>
|
|
</div>
|
|
<p className="subtle tiny">Every {policy.interval_hours}h | Updated {formatTimestamp(policy.updated_at || policy.created_at)}</p>
|
|
<div className="actions">
|
|
<button
|
|
type="button"
|
|
className="secondary"
|
|
onClick={() => {
|
|
setPolicyNamespace(policy.namespace);
|
|
setPolicyPVC(policy.pvc || '');
|
|
setPolicyIntervalHours(policy.interval_hours);
|
|
setPolicyEnabled(policy.enabled);
|
|
}}
|
|
>
|
|
Load
|
|
</button>
|
|
<button type="button" className="secondary" onClick={() => void deletePolicy(policy.id)} disabled={busy}>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|