Migrate Policy retention interfaces

This commit is contained in:
2026-08-03 10:22:46 +02:00
parent 344e15dea4
commit f964ed7dc0
8 changed files with 198 additions and 8 deletions
+3
View File
@@ -12,6 +12,9 @@
"import": "./src/index.ts"
}
},
"scripts": {
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const webuiRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
const panel = readFileSync(resolve(webuiRoot, "src/features/policy/RetentionPoliciesPanel.tsx"), "utf8");
assert.match(panel, /<RetentionPolicyScopeManager/, "Policy delegates effective-policy blockers and provenance to the shared Core contract");
assert.match(panel, /contextId: "policy\.retention"/, "retention exposes stable contextual documentation");
assert.match(panel, /disabledReason=\{actionDisabledReason\}/, "retention execution explains unavailable actions");
assert.match(panel, /<ConfirmDialog/, "destructive retention uses shared confirmation");
assert.match(panel, /<DataGrid/, "retention outcomes use the shared data-grid pattern");
assert.doesNotMatch(panel, /admin-json-preview/, "retention outcome is not presented as raw JSON");
assert.doesNotMatch(panel, /<pre/, "retention outcome is a typed projection");
console.log("Policy interface-pattern contracts passed.");
@@ -5,16 +5,21 @@ import {
Button,
Card,
ConfirmDialog,
DataGrid,
DocumentationHelpLink,
mergeDeltaRows,
RetentionPolicyScopeManager,
runRetentionPolicy,
StatusBadge,
useDeltaWatermarks,
type ApiSettings,
type DataGridColumn,
type DeltaDeletedItem,
type PrivacyRetentionPolicyScope,
type RetentionPolicyTargetOption,
type RetentionRunResponse
} from "@govoplan/core-webui";
import { RefreshCw } from "lucide-react";
import { fetchGroupsDelta, fetchUsersDelta, type GroupListDeltaResponse, type GroupSummary, type UserAdminItem, type UserListDeltaResponse } from "../../api/adminTargets";
type Props = {
@@ -30,6 +35,28 @@ type DeltaResponse = {
full: boolean;
};
interface RetentionCountTree {
[key: string]: number | RetentionCountTree;
}
type RetentionCountRow = {
id: string;
area: string;
measure: string;
count: number;
};
const RETENTION_DOCUMENTATION = {
contextId: "policy.retention",
documentationType: "admin" as const
};
const RETENTION_RESULT_COLUMNS: DataGridColumn<RetentionCountRow>[] = [
{ id: "area", header: "Area", value: (row) => row.area, width: "minmax(180px, 1fr)", filterType: "list", sortable: true },
{ id: "measure", header: "Outcome", value: (row) => row.measure, width: "minmax(220px, 1.4fr)", filterType: "text", sortable: true },
{ id: "count", header: "Records", value: (row) => row.count, width: "120px", align: "right", sortable: true }
];
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
system: {
title: "System retention",
@@ -151,10 +178,38 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
}
const labels = copy[scopeType];
const resultRows = flattenRetentionCounts(retentionResult?.result.counts);
const actionDisabledReason = busy
? "A retention operation is already running."
: !canWrite
? "Your account may inspect retention policy but cannot run retention operations."
: undefined;
return (
<>
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError || runError} success={success}>
<AdminPageLayout
title={labels.title}
description={labels.description}
loading={loadingTargets}
error={targetError || runError}
success={success}
actions={
<>
{(scopeType === "user" || scopeType === "group") && (
<Button
title="Reload policy targets"
aria-label="Reload policy targets"
onClick={() => void loadTargets()}
disabled={loadingTargets}
disabledReason={loadingTargets ? "Policy targets are already loading." : undefined}
>
<RefreshCw size={16} />
</Button>
)}
<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
</>
}
>
<RetentionPolicyScopeManager
settings={settings}
scopeType={scopeType}
@@ -166,22 +221,46 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
/>
{scopeType === "system" && (
<div className="retention-run-card">
<Card title="Retention execution">
<div className="retention-run-section">
<Card
title="Retention execution"
actions={<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
>
<p className="muted small-note">Run the saved effective retention policy against retained platform data.</p>
<div className="button-row compact-actions subsection-bottom-actions">
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>Dry run</Button>
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>Apply retention</Button>
<Button onClick={() => void runRetention(true)} disabled={Boolean(actionDisabledReason)} disabledReason={actionDisabledReason}>Dry run</Button>
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={Boolean(actionDisabledReason)} disabledReason={actionDisabledReason}>Apply retention</Button>
</div>
{retentionResult && <pre className="admin-json-preview">{JSON.stringify(retentionResult.result, null, 2)}</pre>}
</Card>
{retentionResult && (
<Card title="Latest retention outcome">
<dl className="detail-list compact-detail-list">
<div>
<dt>Operation</dt>
<dd><StatusBadge status={retentionResult.result.dry_run ? "info" : "success"} label={retentionResult.result.dry_run ? "Dry run" : "Applied"} /></dd>
</div>
<div><dt>Policy scope</dt><dd>{humanize(retentionResult.result.effective_policy_scope || "system")}</dd></div>
<div><dt>Reported outcomes</dt><dd>{resultRows.length}</dd></div>
</dl>
<div className="admin-table-surface">
<DataGrid
id="policy-retention-outcomes"
rows={resultRows}
columns={RETENTION_RESULT_COLUMNS}
initialFit="container"
getRowKey={(row) => row.id}
emptyText="No retained records currently match the effective policy."
/>
</div>
</Card>
)}
</div>
)}
</AdminPageLayout>
<ConfirmDialog
open={confirmRetentionRun}
title="Apply retention policy"
message="This will redact or delete eligible retained data according to the saved policy."
message="This will redact or delete eligible retained data according to the saved policy. The application cannot restore deleted content; the run and bounded outcome counts remain in audit evidence. Run a dry run first and verify recovery evidence before continuing."
confirmLabel="Apply retention"
tone="danger"
busy={busy}
@@ -192,6 +271,35 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
);
}
function flattenRetentionCounts(
counts: RetentionRunResponse["result"]["counts"] | undefined
): RetentionCountRow[] {
const rows: RetentionCountRow[] = [];
const visit = (value: number | RetentionCountTree, path: string[]) => {
if (typeof value === "number") {
const [area = "retention", ...measureParts] = path;
rows.push({
id: path.join("."),
area: humanize(area),
measure: humanize(measureParts.join(" ") || "records"),
count: value
});
return;
}
for (const [key, child] of Object.entries(value)) visit(child, [...path, key]);
};
if (counts) visit(counts as unknown as RetentionCountTree, []);
return rows;
}
function humanize(value: string): string {
return value
.split(/[._\s-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
async function loadDeltaRows<TItem, TResponse extends DeltaResponse>(
current: TItem[],
key: string,