Add governed Files integrity operations UI

This commit is contained in:
2026-08-04 01:04:39 +02:00
parent 04882f1628
commit 7e6be4b017
15 changed files with 841 additions and 16 deletions
+136
View File
@@ -207,6 +207,57 @@ export type FileConnectorCredentialUpdatePayload = Partial<Omit<FileConnectorCre
clear_token?: boolean;
};
export type FileIntegrityScan = {
id: string;
tenant_id: string;
storage_backend: string;
storage_prefix: string;
status: string;
revision: number;
phase: string;
verify_checksums: boolean;
batch_size: number;
scanned_blob_count: number;
verified_blob_count: number;
quarantined_blob_count: number;
scanned_object_count: number;
orphan_object_count: number;
created_by_user_id?: string | null;
started_at?: string | null;
completed_at?: string | null;
last_error?: string | null;
created_at: string;
updated_at: string;
};
export type FileIntegrityFinding = {
id: string;
scan_id: string;
tenant_id: string;
kind: string;
state: string;
revision: number;
blob_id?: string | null;
storage_key: string;
expected_size_bytes?: number | null;
observed_size_bytes?: number | null;
expected_checksum_sha256?: string | null;
observed_checksum_sha256?: string | null;
resolved_at?: string | null;
resolved_by_user_id?: string | null;
created_at: string;
updated_at: string;
};
export type FileIntegrityActionResult = {
action: string;
changed: boolean;
dry_run: boolean;
finding: FileIntegrityFinding;
inspection_kind?: string | null;
inspection_valid?: boolean | null;
};
export type FileConnectorBrowseItem = {
kind: "library" | "folder" | "file";
name: string;
@@ -1039,6 +1090,91 @@ payload: {
return apiFetch<TransferResponse>(settings, "/api/v1/files/transfer", { method: "POST", body: JSON.stringify(payload) });
}
export async function listFileIntegrityScans(
settings: ApiSettings,
limit = 100
): Promise<FileIntegrityScan[]> {
const response = await apiFetch<{ scans: FileIntegrityScan[] }>(
settings,
`/api/v1/files/integrity/scans?limit=${Math.max(1, Math.min(limit, 200))}`
);
return response.scans;
}
export function createFileIntegrityScan(
settings: ApiSettings,
payload: { verify_checksums: boolean; batch_size: number }
): Promise<FileIntegrityScan> {
return apiFetch<FileIntegrityScan>(settings, "/api/v1/files/integrity/scans", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function runFileIntegrityScanBatch(
settings: ApiSettings,
scan: Pick<FileIntegrityScan, "id" | "revision">
): Promise<FileIntegrityScan> {
return apiFetch<FileIntegrityScan>(
settings,
`/api/v1/files/integrity/scans/${encodeURIComponent(scan.id)}/run`,
{
method: "POST",
body: JSON.stringify({ expected_revision: scan.revision })
}
);
}
export async function listFileIntegrityFindings(
settings: ApiSettings,
scanId: string,
state?: string
): Promise<FileIntegrityFinding[]> {
const search = new URLSearchParams({ limit: "1000" });
if (state) search.set("state", state);
const response = await apiFetch<{ findings: FileIntegrityFinding[] }>(
settings,
`/api/v1/files/integrity/scans/${encodeURIComponent(scanId)}/findings?${search}`
);
return response.findings;
}
export function recheckFileIntegrityFinding(
settings: ApiSettings,
finding: Pick<FileIntegrityFinding, "id" | "revision">,
dryRun = false
): Promise<FileIntegrityActionResult> {
return apiFetch<FileIntegrityActionResult>(
settings,
`/api/v1/files/integrity/findings/${encodeURIComponent(finding.id)}/recheck`,
{
method: "POST",
body: JSON.stringify({
dry_run: dryRun,
expected_revision: finding.revision
})
}
);
}
export function cleanupFileIntegrityFinding(
settings: ApiSettings,
finding: Pick<FileIntegrityFinding, "id" | "revision">,
dryRun: boolean
): Promise<FileIntegrityActionResult> {
return apiFetch<FileIntegrityActionResult>(
settings,
`/api/v1/files/integrity/findings/${encodeURIComponent(finding.id)}/cleanup`,
{
method: "POST",
body: JSON.stringify({
dry_run: dryRun,
expected_revision: finding.revision
})
}
);
}
export async function downloadFile(settings: ApiSettings, file: ManagedFile): Promise<void> {
const response = await fetch(apiUrl(settings, `/api/v1/files/${file.id}/download`), { headers: authHeaders(settings), credentials: "include" });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
@@ -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();
}
+1
View File
@@ -1,6 +1,7 @@
export { default } from "./module";
export * from "./module";
export { default as FilesPage } from "./features/files/FilesPage";
export { default as FileIntegrityPanel } from "./features/files/FileIntegrityPanel";
export * from "./api/files";
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
export { FolderTree } from "./features/files/components/FileManagerComponents";
+16
View File
@@ -16,6 +16,7 @@ import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/file-manager.css";
const FilesPage = lazy(() => import("./features/files/FilesPage"));
const FileIntegrityPanel = lazy(() => import("./features/files/FileIntegrityPanel"));
const fileRead = ["files:file:read"];
const translations = {
@@ -97,6 +98,20 @@ const fileConnectorAdminSections: AdminSectionsUiCapability = {
scopeType: "tenant",
canWrite: hasScope(auth, "admin:settings:write") || hasScope(auth, "files:file:admin")
})
},
{
id: "tenant-file-integrity",
moduleId: "files",
kind: "operations",
surfaceId: "files.admin.tenant-integrity",
label: "File integrity",
group: "TENANT",
order: 66,
allOf: ["files:file:admin"],
render: ({ settings, auth }) => createElement(FileIntegrityPanel, {
settings,
canWrite: hasScope(auth, "files:file:admin")
})
}]
};
@@ -110,6 +125,7 @@ export const filesModule: PlatformWebModule = {
viewSurfaces: [
{ id: "files.admin.system-connectors", moduleId: "files", kind: "section", label: "System file connections", order: 75 },
{ id: "files.admin.tenant-connectors", moduleId: "files", kind: "section", label: "Tenant file connections", order: 65 },
{ id: "files.admin.tenant-integrity", moduleId: "files", kind: "section", label: "File integrity", order: 66 },
{ id: "files.admin.group-connectors", moduleId: "files", kind: "section", label: "Group file connections", order: 65 },
{ id: "files.admin.user-connectors", moduleId: "files", kind: "section", label: "User file connections", order: 65 },
{ id: "files.settings.connectors", moduleId: "files", kind: "section", label: "Personal file connections", order: 20 },