feat: add governed cross-module reporting

This commit is contained in:
2026-08-02 05:29:38 +02:00
parent eba35edd3c
commit 0abcc2455e
21 changed files with 2497 additions and 38 deletions
@@ -0,0 +1,295 @@
import { Download, FileJson, Play, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
Card,
DismissibleAlert,
IconButton,
MetricCard,
StatusBadge,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
downloadProviderExecution,
listProviderParameterOptions,
runProviderReport,
type ProviderReportDescriptor,
type ProviderReportExecution,
type ProviderReportField
} from "../../api/reporting";
export function ProviderReportWorkspace({ settings, auth, report }: {
settings: ApiSettings;
auth: AuthInfo;
report: ProviderReportDescriptor;
}) {
const [parameters, setParameters] = useState<Record<string, unknown>>({});
const [options, setOptions] = useState<Record<string, Array<{ value: string; label: string; description?: string | null }>>>({});
const [purpose, setPurpose] = useState("");
const [execution, setExecution] = useState<ProviderReportExecution | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const tenant = auth.active_tenant ?? auth.tenant;
const audienceScope = useMemo(() => ({
scope_type: "tenant",
scope_id: tenant.id,
label: tenant.name
}), [tenant.id, tenant.name]);
useEffect(() => {
setParameters({});
setPurpose("");
setExecution(null);
setError("");
const controller = new AbortController();
const optionParameters = report.parameters.filter((item) => item.options_from_provider);
void Promise.all(optionParameters.map(async (parameter) => {
const result = await listProviderParameterOptions(
settings,
report,
parameter.key,
"",
controller.signal
);
return [parameter.key, result.options] as const;
})).then((entries) => {
setOptions(Object.fromEntries(entries));
setParameters(Object.fromEntries(entries.flatMap(([key, values]) =>
values[0] ? [[key, values[0].value]] : []
)));
}).catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason));
});
return () => controller.abort();
}, [settings, report.provider_id, report.report_id, report.revision]);
async function run() {
setRunning(true);
setError("");
try {
setExecution(await runProviderReport(
settings,
report,
parameters,
purpose.trim(),
audienceScope
));
} catch (reason) {
setError(message(reason));
} finally {
setRunning(false);
}
}
async function download(format: "json" | "csv") {
if (!execution) return;
try {
await downloadProviderExecution(
settings,
execution,
format,
purpose.trim(),
audienceScope
);
} catch (reason) {
setError(message(reason));
}
}
const missingRequired = report.parameters.some((item) =>
item.required && (parameters[item.key] === undefined || parameters[item.key] === "")
);
return (
<>
<header className="reporting-result-header">
<div className="reporting-title">
<span>{report.provider_id} · {report.revision}</span>
<h1>{report.title}</h1>
<p>{report.summary}</p>
</div>
<div className="reporting-run-actions">
<Button
variant="primary"
onClick={() => void run()}
disabled={!report.available || running || missingRequired || !purpose.trim()}>
<Play size={16} aria-hidden="true" /> {running ? "Running" : "Run"}
</Button>
</div>
</header>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{!report.available &&
<DismissibleAlert tone="warning" dismissible={false}>
{report.unavailable_reason ?? "Policy does not allow this report."}
</DismissibleAlert>
}
<div className="reporting-query-controls reporting-provider-controls">
{report.parameters.map((parameter) =>
<label className="reporting-parameter" key={parameter.key}>
<span>{parameter.label}{parameter.required ? " *" : ""}</span>
{parameter.options_from_provider ?
<select
value={String(parameters[parameter.key] ?? "")}
onChange={(event) => setParameters((current) => ({ ...current, [parameter.key]: event.target.value }))}>
{!parameter.required && <option value="">Current/default</option>}
{(options[parameter.key] ?? []).map((item) =>
<option value={item.value} key={item.value}>{item.label}{item.description ? ` · ${item.description}` : ""}</option>
)}
</select> :
<input
type={parameter.type === "integer" || parameter.type === "number" ? "number" : parameter.type === "date" ? "date" : "text"}
value={String(parameters[parameter.key] ?? "")}
onChange={(event) => setParameters((current) => ({ ...current, [parameter.key]: event.target.value }))}
/>
}
{parameter.description && <small>{parameter.description}</small>}
</label>
)}
<label className="reporting-parameter reporting-provider-purpose">
<span>Purpose *</span>
<input
value={purpose}
maxLength={1000}
onChange={(event) => setPurpose(event.target.value)}
placeholder="Purpose recorded with this execution"
/>
</label>
<label className="reporting-parameter">
<span>Effective audience</span>
<input value={tenant.name} readOnly />
</label>
</div>
{execution ?
<>
<div className="reporting-output-toolbar">
<span>Generated {formatDateTime(execution.generated_at)}</span>
{report.export_formats.includes("csv") &&
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void download("csv")} />
}
{report.export_formats.includes("json") &&
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void download("json")} />
}
</div>
<div className="reporting-provider-output">
<ProviderResult execution={execution} />
</div>
</> :
<div className="reporting-empty">Select the parameters and run this governed report.</div>
}
</>
);
}
export function ProviderReportInspector({ report }: { report: ProviderReportDescriptor }) {
return (
<div className="reporting-inspector-content">
<section className="reporting-provenance">
<h2><ShieldCheck size={16} /> Governance</h2>
<dl>
<dt>Risk</dt><dd><StatusBadge status={report.reidentification_risk === "high" ? "warning" : "active"} label={humanize(report.reidentification_risk)} /></dd>
<dt>Retention</dt><dd>{report.governance.retention_days == null ? "Policy managed" : `${report.governance.retention_days} days`}</dd>
<dt>Exports</dt><dd>{report.governance.export_formats.join(", ") || "Disabled"}</dd>
<dt>Contract</dt><dd>{report.contract_version}</dd>
</dl>
</section>
<section>
<h2>Privacy transforms</h2>
{report.privacy_transforms.map((item) =>
<p key={item.id}>{item.label}{item.required ? " · required" : ""}</p>
)}
</section>
</div>
);
}
function ProviderResult({ execution }: { execution: ProviderReportExecution }) {
const groups = groupFields(execution.result_schema);
return (
<>
{[...groups].map(([group, fields]) => {
const metrics = fields.filter((field) => field.type === "suppressed_count");
const details = fields.filter((field) => field.type !== "suppressed_count");
return (
<Card title={group} key={group}>
{metrics.length > 0 &&
<div className="dashboard-grid reporting-provider-metrics">
{metrics.map((field) =>
<MetricCard key={field.path} label={field.label} value={displayValue(pathValue(execution.result, field.path))} />
)}
</div>
}
{details.length > 0 &&
<dl className="detail-list">
{details.map((field) =>
<div key={field.path}>
<dt>{field.label}</dt>
<dd>{displayValue(pathValue(execution.result, field.path), field)}</dd>
</div>
)}
</dl>
}
</Card>
);
})}
<Card title="Provenance">
<dl className="detail-list">
<div><dt>Purpose</dt><dd>{execution.purpose}</dd></div>
<div><dt>Output hash</dt><dd title={execution.output_hash}>{shortHash(execution.output_hash)}</dd></div>
<div><dt>Source revisions</dt><dd>{execution.source_revisions.length}</dd></div>
<div><dt>Privacy transforms</dt><dd>{execution.privacy_transforms.join(", ")}</dd></div>
<div><dt>Expires</dt><dd>{execution.expires_at ? formatDateTime(execution.expires_at) : "Policy managed"}</dd></div>
</dl>
</Card>
</>
);
}
function groupFields(fields: ProviderReportField[]): Map<string, ProviderReportField[]> {
const groups = new Map<string, ProviderReportField[]>();
for (const field of fields) groups.set(field.group, [...(groups.get(field.group) ?? []), field]);
return groups;
}
function pathValue(payload: Record<string, unknown>, path: string): unknown {
let value: unknown = payload;
for (const part of path.split(".")) {
if (!value || typeof value !== "object") return null;
value = (value as Record<string, unknown>)[part];
}
return value;
}
function displayValue(value: unknown, field?: ProviderReportField): string | number {
if (value && typeof value === "object") {
const count = value as { value?: unknown; suppressed?: boolean };
if (count.suppressed) return "Suppressed";
if ("value" in count) return displayValue(count.value);
return JSON.stringify(value);
}
if (value === null || value === undefined || value === "") return "—";
if (field?.type === "datetime" || field?.type === "date") return formatDateTime(String(value));
if (typeof value === "number") return new Intl.NumberFormat().format(value);
if (typeof value === "boolean") return value ? "Yes" : "No";
return String(value);
}
function formatDateTime(value: string): string {
const date = new Date(value);
return Number.isNaN(date.valueOf()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function humanize(value: string): string {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function shortHash(value: string): string {
return `${value.slice(0, 10)}${value.slice(-6)}`;
}
function message(reason: unknown): string {
return reason instanceof Error ? reason.message : "The provider report could not be loaded.";
}