529 lines
17 KiB
TypeScript
529 lines
17 KiB
TypeScript
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<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 && (
|
|
<>
|
|
<MetricGrid>
|
|
<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"} />
|
|
</MetricGrid>
|
|
{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();
|
|
}
|