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
+137
View File
@@ -119,6 +119,74 @@ export type ReportingSavedView = {
updated_at: string;
};
export type ProviderReportParameter = {
key: string;
label: string;
type: string;
required: boolean;
description?: string | null;
options_from_provider: boolean;
};
export type ProviderReportField = {
path: string;
label: string;
type: string;
group: string;
nullable: boolean;
sensitive: boolean;
};
export type ProviderReportDescriptor = {
contract_version: string;
provider_id: string;
report_id: string;
revision: string;
title: string;
summary: string;
parameters: ProviderReportParameter[];
result_schema: ProviderReportField[];
privacy_transforms: Array<{ id: string; label: string; required: boolean }>;
purpose_required: boolean;
audience_scope_required: boolean;
retention_class: string;
export_formats: Array<"json" | "csv">;
reidentification_risk: "low" | "moderate" | "high";
presentation: { kind?: string };
available: boolean;
unavailable_reason?: string | null;
governance: {
retention_days?: number | null;
export_formats: string[];
required_privacy_transforms: string[];
provenance: Record<string, unknown>;
};
};
export type ProviderReportExecution = {
execution_id: string;
provider_id: string;
report_id: string;
report_revision: string;
contract_version: string;
purpose: string;
audience_scope: Record<string, unknown>;
parameters: Record<string, unknown>;
result_schema: ProviderReportField[];
result: Record<string, unknown>;
source_revisions: Array<Record<string, unknown>>;
effective_scope: Record<string, unknown>;
privacy_transforms: string[];
provenance: Record<string, unknown>;
governance_provenance: Record<string, unknown>;
retention_class: string;
retention_days?: number | null;
expires_at?: string | null;
output_hash: string;
generated_at: string;
actor_id?: string | null;
};
export function listDefinitions(
settings: ApiSettings,
options: { kinds?: ReportingDefinitionKind[]; status?: string[]; query?: string; limit?: number },
@@ -132,6 +200,75 @@ export function listDefinitions(
}), { signal });
}
export function listProviderReports(
settings: ApiSettings,
signal?: AbortSignal
): Promise<{ reports: ProviderReportDescriptor[]; diagnostics: Array<Record<string, string>> }> {
return apiFetch(settings, "/api/v1/reporting/provider-reports", { signal });
}
export function listProviderParameterOptions(
settings: ApiSettings,
report: ProviderReportDescriptor,
parameterKey: string,
query = "",
signal?: AbortSignal
): Promise<{ options: Array<{ value: string; label: string; description?: string | null }> }> {
return apiFetch(settings, apiPath(
`/api/v1/reporting/provider-reports/${encodeURIComponent(report.provider_id)}/${encodeURIComponent(report.report_id)}/parameters/${encodeURIComponent(parameterKey)}/options`,
{ query, limit: 200 }
), { signal });
}
export function runProviderReport(
settings: ApiSettings,
report: ProviderReportDescriptor,
parameters: Record<string, unknown>,
purpose: string,
audienceScope: Record<string, unknown>
): Promise<ProviderReportExecution> {
return apiFetch(
settings,
`/api/v1/reporting/provider-reports/${encodeURIComponent(report.provider_id)}/${encodeURIComponent(report.report_id)}/executions`,
{
method: "POST",
body: JSON.stringify({
parameters,
purpose,
audience_scope: audienceScope,
idempotency_key: crypto.randomUUID()
})
}
);
}
export async function downloadProviderExecution(
settings: ApiSettings,
execution: ProviderReportExecution,
format: "json" | "csv",
purpose: string,
audienceScope: Record<string, unknown>
): Promise<void> {
const response = await fetch(
apiUrl(settings, `/api/v1/reporting/provider-executions/${encodeURIComponent(execution.execution_id)}/exports`),
{
method: "POST",
headers: { ...authHeaders(settings), "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ format, purpose, audience_scope: audienceScope })
}
);
if (!response.ok) throw new Error(await response.text());
const blob = await response.blob();
const disposition = response.headers.get("Content-Disposition") ?? "";
const filename = disposition.match(/filename="?([^";]+)"?/)?.[1] ?? `provider-report.${format}`;
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
export function getDefinition(
settings: ApiSettings,
kind: ReportingDefinitionKind,
@@ -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.";
}
+65 -15
View File
@@ -37,6 +37,7 @@ import {
getDefinition,
listDefinitions,
listExecutions,
listProviderReports,
listSavedViews,
reportPayload,
runReport,
@@ -47,8 +48,13 @@ import {
type ReportingQuery,
type ReportingQueryMode,
type ReportingSavedView,
type ProviderReportDescriptor,
type SemanticModelPayload
} from "../../api/reporting";
import {
ProviderReportInspector,
ProviderReportWorkspace
} from "./ProviderReportWorkspace";
type OutputMode = "visual" | "table";
@@ -57,7 +63,9 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
const [search, setSearch] = useState("");
const [submittedSearch, setSubmittedSearch] = useState("");
const [reports, setReports] = useState<ReportingDefinition[]>([]);
const [providerReports, setProviderReports] = useState<ProviderReportDescriptor[]>([]);
const [selectedId, setSelectedId] = useState("");
const [selectedProviderKey, setSelectedProviderKey] = useState("");
const [semantic, setSemantic] = useState<SemanticModelPayload | null>(null);
const [query, setQuery] = useState<ReportingQuery>(emptyQuery());
const [parameters, setParameters] = useState<Record<string, unknown>>({});
@@ -77,21 +85,42 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
() => reports.find((item) => item.definition_id === selectedId) ?? null,
[reports, selectedId]
);
const selectedProvider = useMemo(
() => providerReports.find((item) => `${item.provider_id}:${item.report_id}` === selectedProviderKey) ?? null,
[providerReports, selectedProviderKey]
);
const report = selected ? reportPayload(selected) : null;
function reload(signal?: AbortSignal) {
setLoading(true);
setError("");
return listDefinitions(settings, {
kinds: ["report"],
status: ["active"],
query: submittedSearch,
limit: 200
}, signal).
then((result) => {
return Promise.all([
listDefinitions(settings, {
kinds: ["report"],
status: ["active"],
query: submittedSearch,
limit: 200
}, signal),
listProviderReports(settings, signal)
]).
then(([result, providerResult]) => {
const providerRows = providerResult.reports.filter((item) => {
const query = submittedSearch.toLocaleLowerCase();
return !query || `${item.title} ${item.summary} ${item.provider_id}`.toLocaleLowerCase().includes(query);
});
setReports(result.definitions);
setSelectedId((current) => result.definitions.some((item) => item.definition_id === current) ?
current : result.definitions[0]?.definition_id ?? "");
setProviderReports(providerRows);
const currentSemanticAvailable = result.definitions.some((item) => item.definition_id === selectedId);
const currentProviderAvailable = providerRows.some((item) => `${item.provider_id}:${item.report_id}` === selectedProviderKey);
if (!currentSemanticAvailable && !currentProviderAvailable) {
if (providerRows[0]) {
setSelectedProviderKey(`${providerRows[0].provider_id}:${providerRows[0].report_id}`);
setSelectedId("");
} else {
setSelectedId(result.definitions[0]?.definition_id ?? "");
setSelectedProviderKey("");
}
}
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason));
@@ -172,7 +201,7 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
placeholder="Search reports"
/>
</form>
<span className="reporting-count">{reports.length} reports</span>
<span className="reporting-count">{reports.length + providerReports.length} reports</span>
<IconButton
label="Reload reports"
icon={<RefreshCw size={17} />}
@@ -188,15 +217,32 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
<div className="reporting-workspace">
<PageScrollViewport className="reporting-catalogue">
{loading && <LoadingIndicator label="Loading reports" />}
{!loading && reports.length === 0 && <div className="reporting-empty">No active reports are available.</div>}
{!loading && reports.length + providerReports.length === 0 && <div className="reporting-empty">No active reports are available.</div>}
<div className="reporting-report-list" role="list">
{providerReports.length > 0 && <div className="reporting-list-heading">Module reports</div>}
{providerReports.map((item) => {
const key = `${item.provider_id}:${item.report_id}`;
return (
<button
type="button"
role="listitem"
key={key}
className={`reporting-report-row${selectedProviderKey === key ? " is-selected" : ""}`}
onClick={() => { setSelectedProviderKey(key); setSelectedId(""); }}>
<BarChart3 size={17} aria-hidden="true" />
<span><strong>{item.title}</strong><small>{item.provider_id} · {item.revision}</small></span>
<StatusBadge status={item.available ? "active" : "locked"} label={item.available ? "Available" : "Restricted"} />
</button>
);
})}
{reports.length > 0 && <div className="reporting-list-heading">Semantic reports</div>}
{reports.map((item) =>
<button
type="button"
role="listitem"
key={item.definition_id}
className={`reporting-report-row${selectedId === item.definition_id ? " is-selected" : ""}`}
onClick={() => setSelectedId(item.definition_id)}>
onClick={() => { setSelectedId(item.definition_id); setSelectedProviderKey(""); }}>
<BarChart3 size={17} aria-hidden="true" />
<span><strong>{item.name}</strong><small>{item.definition_key} · r{item.revision}</small></span>
<StatusBadge status="active" label="Active" />
@@ -205,7 +251,9 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
</div>
</PageScrollViewport>
<section className="reporting-result-region">
{selected && report ?
{selectedProvider ?
<ProviderReportWorkspace settings={settings} auth={auth} report={selectedProvider} /> :
selected && report ?
<>
<header className="reporting-result-header">
<div className="reporting-title">
@@ -258,14 +306,16 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
}
</section>
<PageScrollViewport className="reporting-inspector">
<Inspector
{selectedProvider ?
<ProviderReportInspector report={selectedProvider} /> :
<Inspector
selected={selected}
execution={execution}
history={history}
savedViews={savedViews}
onSelectExecution={setExecution}
onApplySavedView={applySavedView}
/>
/>}
</PageScrollViewport>
</div>
</div>
+9 -2
View File
@@ -24,16 +24,23 @@ export const reportingModule: PlatformWebModule = {
],
routes: [
{
path: "/reporting",
path: "/reports",
anyOf: ["reporting:definition:read"],
order: 74,
surfaceId: "reporting.workspace",
render: (context) => createElement(ReportingPage, context)
},
{
path: "/reporting",
anyOf: ["reporting:definition:read"],
order: 175,
surfaceId: "reporting.compatibility",
render: (context) => createElement(ReportingPage, context)
}
],
navItems: [
{
to: "/reporting",
to: "/reports",
label: "Reporting",
iconName: "clipboard-pen-line",
anyOf: ["reporting:definition:read"],
+43
View File
@@ -74,6 +74,16 @@
background: var(--surface-raised);
}
.reporting-list-heading {
padding: 7px 10px;
border-bottom: 1px solid var(--border);
background: var(--surface-subtle, var(--surface));
color: var(--text-soft);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
}
.reporting-report-row,
.reporting-inspector-content section > button {
display: grid;
@@ -162,6 +172,16 @@
white-space: nowrap;
}
.reporting-title p {
max-width: 70ch;
margin: 3px 0 0;
overflow: hidden;
color: var(--text-soft);
font-size: 0.76rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.reporting-run-actions {
display: flex;
align-items: center;
@@ -223,6 +243,14 @@
gap: 4px;
}
.reporting-provider-controls {
align-items: end;
}
.reporting-provider-purpose {
min-width: min(320px, 35vw);
}
.reporting-output-toolbar {
min-height: 48px;
padding: 6px 12px;
@@ -241,6 +269,21 @@
padding: 10px;
}
.reporting-provider-output {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 10px;
overflow: auto;
padding: 10px;
}
.reporting-provider-metrics {
margin: 0;
padding: 0;
}
.reporting-output .data-grid-shell {
min-width: 100%;
}