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.";
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user