feat: add governed cross-module reporting
This commit is contained in:
@@ -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.";
|
||||
}
|
||||
Reference in New Issue
Block a user