import { MetricGrid } from "@govoplan/core-webui"; import { useEffect, useMemo, useState } from "react"; import { AdminPageLayout, adminErrorMessage, Button, Card, ConfirmDialog, DataGrid, Dialog, DismissibleAlert, DocumentationHelpLink, FormField, MetricCard, StatusBadge, TableActionGroup, ToggleSwitch, type ApiSettings, type DataGridColumn } from "@govoplan/core-webui"; import { CheckCircle2, Eye, Play, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react"; import { cleanupFileIntegrityFinding, createFileIntegrityScan, listFileIntegrityFindings, listFileIntegrityScans, recheckFileIntegrityFinding, runFileIntegrityScanBatch, type FileIntegrityActionResult, type FileIntegrityFinding, type FileIntegrityScan } from "../../api/files"; type Props = { settings: ApiSettings; canWrite: boolean; }; const DOCUMENTATION = { topicId: "files.reference.integrity-recovery-and-fail-closed-transports", documentationType: "admin" as const }; export default function FileIntegrityPanel({ settings, canWrite }: Props) { const [scans, setScans] = useState([]); const [selectedScanId, setSelectedScanId] = useState(""); const [findings, setFindings] = useState([]); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [verifyChecksums, setVerifyChecksums] = useState(true); const [batchSize, setBatchSize] = useState(100); const [cleanupPreview, setCleanupPreview] = useState(null); const selectedScan = scans.find((scan) => scan.id === selectedScanId) ?? null; useEffect(() => { void loadScans(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey]); useEffect(() => { if (!selectedScanId) { setFindings([]); return; } void loadFindings(selectedScanId); }, [selectedScanId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); async function loadScans() { setLoading(true); setError(""); try { const loaded = await listFileIntegrityScans(settings); setScans(loaded); setSelectedScanId((current) => ( loaded.some((scan) => scan.id === current) ? current : loaded[0]?.id ?? "" )); } catch (err) { setError(adminErrorMessage(err)); } finally { setLoading(false); } } async function loadFindings(scanId: string) { setError(""); try { setFindings(await listFileIntegrityFindings(settings, scanId)); } catch (err) { setError(adminErrorMessage(err)); } } function replaceScan(next: FileIntegrityScan) { setScans((current) => [ next, ...current.filter((scan) => scan.id !== next.id) ].sort((left, right) => right.created_at.localeCompare(left.created_at))); } function replaceFinding(next: FileIntegrityFinding) { setFindings((current) => current.map((finding) => ( finding.id === next.id ? next : finding ))); } async function createScan() { setBusy(true); setError(""); setSuccess(""); try { const scan = await createFileIntegrityScan(settings, { verify_checksums: verifyChecksums, batch_size: batchSize }); replaceScan(scan); setSelectedScanId(scan.id); setCreateOpen(false); setSuccess("Integrity scan created. Run its first bounded batch when ready."); } catch (err) { setError(adminErrorMessage(err)); } finally { setBusy(false); } } async function runBatch(scan: FileIntegrityScan) { setBusy(true); setError(""); setSuccess(""); try { const next = await runFileIntegrityScanBatch(settings, scan); replaceScan(next); setSelectedScanId(next.id); await loadFindings(next.id); setSuccess( next.status === "completed" ? "Integrity scan completed. Review and resolve every finding." : `Completed one ${next.batch_size}-item batch; the scan can be resumed.` ); } catch (err) { setError(adminErrorMessage(err)); await loadScans(); } finally { setBusy(false); } } async function recheck(finding: FileIntegrityFinding) { setBusy(true); setError(""); setSuccess(""); try { const result = await recheckFileIntegrityFinding(settings, finding); replaceFinding(result.finding); setSuccess( result.inspection_valid ? "The stored object now matches its recorded integrity evidence." : "The object still fails integrity verification and remains quarantined." ); } catch (err) { setError(adminErrorMessage(err)); if (selectedScanId) await loadFindings(selectedScanId); } finally { setBusy(false); } } async function previewCleanup(finding: FileIntegrityFinding) { setBusy(true); setError(""); setSuccess(""); try { const preview = await cleanupFileIntegrityFinding(settings, finding, true); setCleanupPreview(preview); if (preview.action !== "would_delete") { replaceFinding(preview.finding); setSuccess(cleanupActionMessage(preview.action)); } } catch (err) { setError(adminErrorMessage(err)); if (selectedScanId) await loadFindings(selectedScanId); } finally { setBusy(false); } } async function confirmCleanup() { if (!cleanupPreview) return; setBusy(true); setError(""); setSuccess(""); try { const result = await cleanupFileIntegrityFinding( settings, cleanupPreview.finding, false ); replaceFinding(result.finding); setCleanupPreview(null); setSuccess(cleanupActionMessage(result.action)); } catch (err) { setCleanupPreview(null); setError(adminErrorMessage(err)); if (selectedScanId) await loadFindings(selectedScanId); } finally { setBusy(false); } } const scanColumns = useMemo[]>(() => [ { id: "created", header: "Created", width: 180, minWidth: 150, sortable: true, value: (scan) => scan.created_at, render: (scan) => formatDateTime(scan.created_at) }, { id: "status", header: "Status", width: 130, minWidth: 110, sortable: true, filterable: true, filterType: "list", value: (scan) => scan.status, render: (scan) => }, { id: "phase", header: "Phase", width: 110, minWidth: 90, value: (scan) => scan.phase }, { id: "progress", header: "Progress", minWidth: 250, resizable: true, value: (scan) => `${scan.scanned_blob_count} blobs, ${scan.scanned_object_count} objects`, render: (scan) => ( {scan.scanned_blob_count} blobs ({scan.verified_blob_count} verified, {scan.quarantined_blob_count} quarantined), {" "} {scan.scanned_object_count} objects ({scan.orphan_object_count} orphaned) ) }, { id: "backend", header: "Storage", width: 120, minWidth: 100, value: (scan) => scan.storage_backend }, { id: "actions", header: "Actions", width: 105, minWidth: 105, sticky: "end", align: "right", render: (scan) => ( , onClick: () => setSelectedScanId(scan.id) }, { id: "run", label: scan.status === "failed" ? "Resume next batch" : "Run next batch", icon: , onClick: () => void runBatch(scan), disabled: busy || ["completed", "cancelled"].includes(scan.status), disabledReason: ["completed", "cancelled"].includes(scan.status) ? "This scan is complete." : undefined } ]} /> ) } ], [busy]); const findingColumns = useMemo[]>(() => [ { id: "kind", header: "Finding", width: 180, minWidth: 145, sortable: true, filterable: true, value: (finding) => finding.kind.replaceAll("_", " ") }, { id: "state", header: "State", width: 115, minWidth: 95, sortable: true, filterable: true, value: (finding) => finding.state, render: (finding) => }, { id: "object", header: "Affected object", minWidth: 260, resizable: true, filterable: true, value: (finding) => finding.storage_key, render: (finding) => ( {finding.blob_id ? `Blob ${finding.blob_id}` : finding.storage_key} ) }, { id: "evidence", header: "Expected / observed", minWidth: 230, resizable: true, value: (finding) => evidenceLabel(finding) }, { id: "updated", header: "Updated", width: 175, minWidth: 145, sortable: true, value: (finding) => finding.updated_at, render: (finding) => formatDateTime(finding.updated_at) }, { id: "actions", header: "Actions", width: 70, minWidth: 70, sticky: "end", align: "right", render: (finding) => ( , variant: "danger", onClick: () => void previewCleanup(finding), disabled: busy || finding.state === "deleted", disabledReason: finding.state === "deleted" ? "The object is already absent." : undefined }] : [{ id: "recheck", label: "Recheck stored object", icon: , onClick: () => void recheck(finding), disabled: busy }]} /> ) } ], [busy]); return ( <> )} > Cleanup is available only for objects with no managed database reference and always starts with a dry-run preview. Files does not yet implement legal-hold or hard-purge policy; such objects must remain outside cleanup until those controls exist.
scan.id} initialFit="container" rowClassName={(scan) => scan.id === selectedScanId ? "is-selected" : undefined} emptyText="No integrity scans have been created." />
{selectedScan && ( <> finding.state === "open").length} tone={findings.some((finding) => finding.state === "open") ? "warning" : "good"} /> {selectedScan.last_error && ( The last batch failed with {selectedScan.last_error}. Verify storage availability, then resume from the recorded cursor. )}
finding.id} initialFit="container" emptyText={selectedScan.status === "completed" ? "No findings were recorded." : "No findings in completed batches yet."} />
)}
setCreateOpen(false)} closeDisabled={busy} footer={( <> )} >
setBatchSize(Math.max(1, Math.min(1000, Number(event.target.value) || 1)))} disabled={busy} />
void confirmCleanup()} onCancel={() => setCleanupPreview(null)} /> ); } function statusTone(value: string): string { if (["completed", "resolved", "verified"].includes(value)) return "success"; if (["failed", "deleted", "missing", "checksum_mismatch", "size_mismatch"].includes(value)) return "danger"; if (["running", "pending", "open"].includes(value)) return "warning"; return "neutral"; } function evidenceLabel(finding: FileIntegrityFinding): string { const expected = finding.expected_size_bytes == null ? "-" : `${finding.expected_size_bytes} B`; const observed = finding.observed_size_bytes == null ? "-" : `${finding.observed_size_bytes} B`; return `${expected} / ${observed}`; } function cleanupActionMessage(action: string): string { if (action === "retained_referenced") return "Cleanup was blocked because the object has a managed database reference."; if (action === "already_deleted" || action === "already_absent") return "The object was already absent; the finding is reconciled."; if (action === "deleted") return "The unreferenced storage object was deleted and recovery evidence was recorded."; return `Integrity cleanup result: ${action.replaceAll("_", " ")}.`; } function formatDateTime(value: string | null | undefined): string { if (!value) return "-"; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString(); }