soteria/web/src/App.tsx

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;