Add governed Files integrity operations UI
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
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<FileIntegrityScan[]>([]);
|
||||
const [selectedScanId, setSelectedScanId] = useState("");
|
||||
const [findings, setFindings] = useState<FileIntegrityFinding[]>([]);
|
||||
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<FileIntegrityActionResult | null>(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<DataGridColumn<FileIntegrityScan>[]>(() => [
|
||||
{
|
||||
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) => <StatusBadge status={statusTone(scan.status)} label={scan.status} />
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<span>
|
||||
{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)
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<TableActionGroup
|
||||
minimumSlots={2}
|
||||
actions={[
|
||||
{
|
||||
id: "inspect",
|
||||
label: "Inspect findings",
|
||||
icon: <Eye size={16} />,
|
||||
onClick: () => setSelectedScanId(scan.id)
|
||||
},
|
||||
{
|
||||
id: "run",
|
||||
label: scan.status === "failed" ? "Resume next batch" : "Run next batch",
|
||||
icon: <Play size={16} />,
|
||||
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<DataGridColumn<FileIntegrityFinding>[]>(() => [
|
||||
{
|
||||
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) => <StatusBadge status={statusTone(finding.state)} label={finding.state} />
|
||||
},
|
||||
{
|
||||
id: "object",
|
||||
header: "Affected object",
|
||||
minWidth: 260,
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
value: (finding) => finding.storage_key,
|
||||
render: (finding) => (
|
||||
<span title={finding.storage_key}>
|
||||
{finding.blob_id ? `Blob ${finding.blob_id}` : finding.storage_key}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<TableActionGroup
|
||||
minimumSlots={1}
|
||||
actions={finding.kind === "orphan_object"
|
||||
? [{
|
||||
id: "cleanup",
|
||||
label: "Preview safe cleanup",
|
||||
icon: <Trash2 size={16} />,
|
||||
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: <RotateCw size={16} />,
|
||||
onClick: () => void recheck(finding),
|
||||
disabled: busy
|
||||
}]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
], [busy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="File integrity"
|
||||
description="Run bounded storage reconciliation and resolve quarantined or unreferenced objects without acting on stale operator state."
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={(
|
||||
<>
|
||||
<Button
|
||||
title="Reload scans and findings"
|
||||
aria-label="Reload scans and findings"
|
||||
onClick={() => void loadScans()}
|
||||
disabled={loading || busy}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)} disabled={!canWrite || busy}>
|
||||
<Plus size={16} /> New scan
|
||||
</Button>
|
||||
<DocumentationHelpLink reference={DOCUMENTATION} label="Open Files integrity documentation" />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<DismissibleAlert tone="info" dismissible={false} compact>
|
||||
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.
|
||||
</DismissibleAlert>
|
||||
|
||||
<Card title="Integrity scans">
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid
|
||||
id="files-integrity-scans-v1"
|
||||
rows={scans}
|
||||
columns={scanColumns}
|
||||
getRowKey={(scan) => scan.id}
|
||||
initialFit="container"
|
||||
rowClassName={(scan) => scan.id === selectedScanId ? "is-selected" : undefined}
|
||||
emptyText="No integrity scans have been created."
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{selectedScan && (
|
||||
<>
|
||||
<div className="metric-grid">
|
||||
<MetricCard label="Verified blobs" value={selectedScan.verified_blob_count} tone="good" />
|
||||
<MetricCard label="Quarantined blobs" value={selectedScan.quarantined_blob_count} tone={selectedScan.quarantined_blob_count ? "danger" : "neutral"} />
|
||||
<MetricCard label="Orphan objects" value={selectedScan.orphan_object_count} tone={selectedScan.orphan_object_count ? "warning" : "neutral"} />
|
||||
<MetricCard label="Open findings" value={findings.filter((finding) => finding.state === "open").length} tone={findings.some((finding) => finding.state === "open") ? "warning" : "good"} />
|
||||
</div>
|
||||
{selectedScan.last_error && (
|
||||
<DismissibleAlert tone="warning" dismissible={false} compact>
|
||||
The last batch failed with {selectedScan.last_error}. Verify storage availability, then resume from the recorded cursor.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
<Card title="Findings">
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid
|
||||
id={`files-integrity-findings-${selectedScan.id}`}
|
||||
rows={findings}
|
||||
columns={findingColumns}
|
||||
getRowKey={(finding) => finding.id}
|
||||
initialFit="container"
|
||||
emptyText={selectedScan.status === "completed" ? "No findings were recorded." : "No findings in completed batches yet."}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
title="Create integrity scan"
|
||||
onClose={() => setCreateOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setCreateOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void createScan()} disabled={busy}>
|
||||
<CheckCircle2 size={16} /> {busy ? "Creating..." : "Create scan"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="settings-list">
|
||||
<ToggleSwitch
|
||||
label="Verify SHA-256 checksums"
|
||||
checked={verifyChecksums}
|
||||
onChange={setVerifyChecksums}
|
||||
disabled={busy}
|
||||
help="Checksum verification reads every selected object; disabling it verifies existence and size only."
|
||||
/>
|
||||
<FormField label="Items per batch" documentation={DOCUMENTATION}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={batchSize}
|
||||
onChange={(event) => setBatchSize(Math.max(1, Math.min(1000, Number(event.target.value) || 1)))}
|
||||
disabled={busy}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={cleanupPreview?.action === "would_delete"}
|
||||
title="Delete unreferenced storage object?"
|
||||
message={cleanupPreview ? `The dry run confirmed that ${cleanupPreview.finding.storage_key} has no managed Files reference. Deletion cannot be undone from GovOPlaN.` : ""}
|
||||
confirmLabel="Delete object"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
onConfirm={() => 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();
|
||||
}
|
||||
Reference in New Issue
Block a user