soteria/web/src/components/PVCInventoryPanel.tsx

137 lines
6.5 KiB
TypeScript

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<void>;
onOpenNamespaceSelection: (namespace: string) => void;
onTriggerBackup: (namespace: string, pvc: string) => void | Promise<void>;
onOpenPVCSelection: (namespace: string, pvc: string) => void | Promise<void>;
}
export function PVCInventoryPanel({
inventory,
inventoryError,
manualDedupe,
manualKeepLast,
busy,
onManualDedupeChange,
onManualKeepLastChange,
onTriggerNamespaceBackup,
onOpenNamespaceSelection,
onTriggerBackup,
onOpenPVCSelection
}: PVCInventoryPanelProps) {
return (
<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>
<label className="checkbox-row">
<input type="checkbox" checked={manualDedupe} onChange={(event) => onManualDedupeChange(event.target.checked)} />
Dedupe unchanged blocks (default)
</label>
<label>
Keep last snapshots per PVC (0 = keep all)
<input
type="number"
min={0}
max={1000}
value={manualKeepLast}
onChange={(event) => onManualKeepLastChange(Math.max(0, Math.min(1000, Number(event.target.value || 0))))}
/>
</label>
<p className="subtle tiny">This setting applies to both `Backup now` and `Backup namespace` actions.</p>
{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 onTriggerNamespaceBackup(namespace.name)} disabled={busy}>
Backup namespace
</button>
<button type="button" className="secondary" onClick={() => onOpenNamespaceSelection(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;
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 (
<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: {latestSizeLabel} | Total stored: {totalStoredLabel}
</p>
{showResticSizeHint && (
<p className="subtle tiny">
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.
</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 onTriggerBackup(pvc.namespace, pvc.pvc)} disabled={busy}>
Backup now
</button>
<button type="button" className="secondary" onClick={() => void onOpenPVCSelection(pvc.namespace, pvc.pvc)}>
Restore
</button>
</div>
</article>
);
})}
</div>
</article>
))}
</section>
);
}