316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
import { DescriptionList } from "@govoplan/core-webui";
|
|
import { Download, FileJson, Play, ShieldCheck } from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { ContentGrid,
|
|
Button,
|
|
Card,
|
|
DismissibleAlert,
|
|
IconButton,
|
|
MetricCard,
|
|
StatePanel,
|
|
StatusBadge,
|
|
WorkspaceActionBar,
|
|
hasScope,
|
|
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 canRun = hasScope(auth, "reporting:report:run");
|
|
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] === "")
|
|
);
|
|
const runDisabledReason = !canRun
|
|
? "Report run permission is required."
|
|
: !report.available
|
|
? report.unavailable_reason ?? "Policy does not allow this report."
|
|
: missingRequired
|
|
? "Complete the required report parameters."
|
|
: !purpose.trim()
|
|
? "Record the purpose for this governed report run."
|
|
: undefined;
|
|
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={running || Boolean(runDisabledReason)}
|
|
disabledReason={runDisabledReason}>
|
|
<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 ?
|
|
<>
|
|
<WorkspaceActionBar
|
|
scope="detail-pane"
|
|
variant="detail"
|
|
className="reporting-output-toolbar"
|
|
contextActions={<span>Generated {formatDateTime(execution.generated_at)}</span>}
|
|
primaryActions={<>
|
|
{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 className="reporting-provider-output">
|
|
<ProviderResult execution={execution} />
|
|
</div>
|
|
</> :
|
|
<StatePanel size="fill" description="Select the parameters and run this governed report." />
|
|
}
|
|
</>
|
|
);
|
|
}
|
|
|
|
|
|
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 &&
|
|
<ContentGrid columns={2} collapseAt="workspace" className="reporting-provider-metrics">
|
|
{metrics.map((field) =>
|
|
<MetricCard key={field.path} label={field.label} value={displayValue(pathValue(execution.result, field.path))} />
|
|
)}
|
|
</ContentGrid>
|
|
}
|
|
{details.length > 0 &&
|
|
<DescriptionList variant="inline">
|
|
{details.map((field) =>
|
|
<div key={field.path}>
|
|
<dt>{field.label}</dt>
|
|
<dd>{displayValue(pathValue(execution.result, field.path), field)}</dd>
|
|
</div>
|
|
)}
|
|
</DescriptionList>
|
|
}
|
|
</Card>
|
|
);
|
|
})}
|
|
<Card title="Provenance">
|
|
<DescriptionList variant="inline">
|
|
<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>
|
|
</DescriptionList>
|
|
</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.";
|
|
}
|