feat: implement governed reporting vertical

This commit is contained in:
2026-08-01 17:48:39 +02:00
parent 720959536b
commit eba35edd3c
35 changed files with 8414 additions and 33 deletions
@@ -0,0 +1,573 @@
import {
BarChart3,
CalendarClock,
Download,
FileJson,
History,
Play,
RefreshCw,
Save,
Search,
SlidersHorizontal,
Table2
} from "lucide-react";
import {
useEffect,
useMemo,
useState,
type FormEvent
} from "react";
import {
Button,
DataGrid,
Dialog,
DismissibleAlert,
IconButton,
LoadingIndicator,
PageScrollViewport,
SegmentedControl,
StatusBadge,
hasScope,
type DataGridColumn,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
createIntervalSchedule,
downloadExecution,
getDefinition,
listDefinitions,
listExecutions,
listSavedViews,
reportPayload,
runReport,
saveView,
semanticPayload,
type ReportExecution,
type ReportingDefinition,
type ReportingQuery,
type ReportingQueryMode,
type ReportingSavedView,
type SemanticModelPayload
} from "../../api/reporting";
type OutputMode = "visual" | "table";
export default function ReportingPage({ settings, auth }: PlatformRouteContext) {
const [search, setSearch] = useState("");
const [submittedSearch, setSubmittedSearch] = useState("");
const [reports, setReports] = useState<ReportingDefinition[]>([]);
const [selectedId, setSelectedId] = useState("");
const [semantic, setSemantic] = useState<SemanticModelPayload | null>(null);
const [query, setQuery] = useState<ReportingQuery>(emptyQuery());
const [parameters, setParameters] = useState<Record<string, unknown>>({});
const [execution, setExecution] = useState<ReportExecution | null>(null);
const [history, setHistory] = useState<ReportExecution[]>([]);
const [savedViews, setSavedViews] = useState<ReportingSavedView[]>([]);
const [outputMode, setOutputMode] = useState<OutputMode>("visual");
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
const canRun = hasScope(auth, "reporting:report:run");
const canSchedule = hasScope(auth, "reporting:schedule:write");
const selected = useMemo(
() => reports.find((item) => item.definition_id === selectedId) ?? null,
[reports, selectedId]
);
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) => {
setReports(result.definitions);
setSelectedId((current) => result.definitions.some((item) => item.definition_id === current) ?
current : result.definitions[0]?.definition_id ?? "");
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason));
}).
finally(() => setLoading(false));
}
useEffect(() => {
const controller = new AbortController();
void reload(controller.signal);
return () => controller.abort();
}, [settings, submittedSearch]);
useEffect(() => {
if (!selected || !report) {
setSemantic(null);
setExecution(null);
setHistory([]);
setSavedViews([]);
return;
}
const controller = new AbortController();
setQuery(normalizeQuery(report.default_query));
setParameters(defaultParameters(report.parameters));
setExecution(null);
Promise.all([
getDefinition(settings, "semantic_model", report.semantic_model_id, report.semantic_model_revision, controller.signal),
canRun ? listExecutions(settings, selected.definition_id, controller.signal) : Promise.resolve({ executions: [] }),
listSavedViews(settings, selected.definition_id, controller.signal)
]).
then(([semanticDefinition, executions, views]) => {
setSemantic(semanticPayload(semanticDefinition));
setHistory(executions.executions);
setSavedViews(views.views);
setExecution(executions.executions.find((item) => item.status === "succeeded") ?? null);
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason));
});
return () => controller.abort();
}, [settings, selectedId]);
function submitSearch(event: FormEvent) {
event.preventDefault();
setSubmittedSearch(search.trim());
}
async function execute() {
if (!selected) return;
setRunning(true);
setError("");
try {
const result = await runReport(settings, selected, query, parameters);
setExecution(result);
setHistory((current) => [result, ...current.filter((item) => item.execution_id !== result.execution_id)]);
setOutputMode(result.visualization?.kind === "table" ? "table" : "visual");
} catch (reason) {
setError(message(reason));
} finally {
setRunning(false);
}
}
function applySavedView(view: ReportingSavedView) {
if (view.state.query) setQuery(normalizeQuery(view.state.query));
}
return (
<main className="reporting-page">
<div className="reporting-shell">
<div className="reporting-toolbar">
<form className="reporting-search" onSubmit={submitSearch}>
<Search size={17} aria-hidden="true" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
aria-label="Search reports"
placeholder="Search reports"
/>
</form>
<span className="reporting-count">{reports.length} reports</span>
<IconButton
label="Reload reports"
icon={<RefreshCw size={17} />}
variant="ghost"
onClick={() => void reload()}
/>
</div>
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
<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>}
<div className="reporting-report-list" role="list">
{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)}>
<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" />
</button>
)}
</div>
</PageScrollViewport>
<section className="reporting-result-region">
{selected && report ?
<>
<header className="reporting-result-header">
<div className="reporting-title">
<span>{selected.definition_key} · revision {selected.revision}</span>
<h1>{selected.name}</h1>
</div>
<div className="reporting-run-actions">
{canSchedule &&
<IconButton label="Schedule report" icon={<CalendarClock size={17} />} variant="ghost" onClick={() => setScheduleDialogOpen(true)} />
}
<IconButton label="Save current view" icon={<Save size={17} />} variant="ghost" onClick={() => setSaveDialogOpen(true)} />
<Button variant="primary" onClick={() => void execute()} disabled={!canRun || running}>
<Play size={16} aria-hidden="true" /> {running ? "Running" : "Run"}
</Button>
</div>
</header>
<QueryControls
query={query}
semantic={semantic}
parameters={report.parameters}
parameterValues={parameters}
onQueryChange={setQuery}
onParametersChange={setParameters}
/>
<div className="reporting-output-toolbar">
<SegmentedControl
ariaLabel="Report output"
value={outputMode}
onChange={setOutputMode}
options={[
{ id: "visual", label: <><BarChart3 size={15} /> Visual</> },
{ id: "table", label: <><Table2 size={15} /> Table</> }
]}
/>
{execution &&
<>
<span>{execution.total_rows} rows{execution.truncated ? " (truncated)" : ""}</span>
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "csv").catch((reason) => setError(message(reason)))} />
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "json").catch((reason) => setError(message(reason)))} />
</>
}
</div>
<div className="reporting-output">
{!execution && <div className="reporting-empty">Run the report or select a previous execution.</div>}
{execution && outputMode === "visual" && <ReportVisual execution={execution} />}
{execution && outputMode === "table" && <ReportTable execution={execution} />}
</div>
</> :
<div className="reporting-empty">Select a report.</div>
}
</section>
<PageScrollViewport className="reporting-inspector">
<Inspector
selected={selected}
execution={execution}
history={history}
savedViews={savedViews}
onSelectExecution={setExecution}
onApplySavedView={applySavedView}
/>
</PageScrollViewport>
</div>
</div>
<SaveViewDialog
open={saveDialogOpen}
onClose={() => setSaveDialogOpen(false)}
onSave={async (name) => {
if (!selected) return;
const saved = await saveView(settings, selected, name, query);
setSavedViews((current) => [...current, saved].sort((a, b) => a.name.localeCompare(b.name)));
setSaveDialogOpen(false);
}}
/>
<ScheduleDialog
open={scheduleDialogOpen}
onClose={() => setScheduleDialogOpen(false)}
onSave={async (name, seconds) => {
if (!selected) return;
await createIntervalSchedule(settings, selected, name, seconds, query, parameters);
setScheduleDialogOpen(false);
}}
/>
</main>
);
}
function QueryControls({ query, semantic, parameters, parameterValues, onQueryChange, onParametersChange }: {
query: ReportingQuery;
semantic: SemanticModelPayload | null;
parameters: Array<{ key: string; label: string; type: string; required: boolean; default?: unknown; allowed_values: unknown[] }>;
parameterValues: Record<string, unknown>;
onQueryChange: (query: ReportingQuery) => void;
onParametersChange: (parameters: Record<string, unknown>) => void;
}) {
const dimensions = semantic?.dimensions ?? [];
const measures = semantic?.measures ?? [];
function setMode(mode: ReportingQueryMode) {
const row = query.dimensions[0] ?? dimensions[0]?.key;
const column = query.dimensions[1] ?? dimensions[1]?.key;
onQueryChange({
...query,
mode,
pivot: mode === "pivot" ? {
rows: row ? [row] : [],
columns: column ? [column] : [],
measures: query.measures.length ? query.measures : measures.slice(0, 1).map((item) => item.key),
include_totals: true
} : null
});
}
return (
<div className="reporting-query-controls">
<div className="reporting-query-heading">
<SlidersHorizontal size={16} aria-hidden="true" />
<SegmentedControl
ariaLabel="Query shape"
value={query.mode}
onChange={setMode}
options={[
{ id: "summary", label: "Summary" },
{ id: "detail", label: "Detail" },
{ id: "pivot", label: "Pivot" }
]}
/>
</div>
<fieldset>
<legend>Dimensions</legend>
<div className="reporting-option-list">
{dimensions.map((item) =>
<label key={item.key}>
<input
type="checkbox"
checked={query.dimensions.includes(item.key)}
onChange={() => onQueryChange({ ...query, dimensions: toggle(query.dimensions, item.key) })}
/>
{item.label}
</label>
)}
</div>
</fieldset>
{query.mode !== "detail" &&
<fieldset>
<legend>Measures</legend>
<div className="reporting-option-list">
{measures.map((item) =>
<label key={item.key}>
<input
type="checkbox"
checked={query.measures.includes(item.key)}
onChange={() => onQueryChange({ ...query, measures: toggle(query.measures, item.key) })}
/>
{item.label}
</label>
)}
</div>
</fieldset>
}
{parameters.map((parameter) =>
<label className="reporting-parameter" key={parameter.key}>
<span>{parameter.label}</span>
{parameter.allowed_values.length > 0 ?
<select
value={String(parameterValues[parameter.key] ?? "")}
onChange={(event) => onParametersChange({ ...parameterValues, [parameter.key]: event.target.value })}>
{!parameter.required && <option value="">Any</option>}
{parameter.allowed_values.map((value) => <option key={String(value)} value={String(value)}>{String(value)}</option>)}
</select> :
<input
type={parameter.type === "number" || parameter.type === "integer" ? "number" : "text"}
value={String(parameterValues[parameter.key] ?? "")}
onChange={(event) => onParametersChange({ ...parameterValues, [parameter.key]: typedValue(event.target.value, parameter.type) })}
/>
}
</label>
)}
</div>
);
}
function ReportTable({ execution }: { execution: ReportExecution }) {
const [page, setPage] = useState(0);
useEffect(() => setPage(0), [execution.execution_id]);
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() =>
execution.schema.map((field) => ({
id: field.name,
header: humanize(field.name),
width: "1fr",
minWidth: 120,
resizable: true,
sortable: true,
filterable: true,
filterType: field.type === "integer" || field.type === "number" ? field.type : "text",
value: (row) => row[field.name],
render: (row) => formatValue(row[field.name])
})), [execution]);
return (
<DataGrid
id={`reporting-execution-${execution.execution_id}`}
rows={execution.rows}
columns={columns}
getRowKey={(_row, index) => `${execution.execution_id}:${index}`}
initialFit="container"
resizeBehavior="cover"
emptyText="The report returned no rows."
pagination={{ page, pageSize: 50, onPageChange: setPage }}
/>
);
}
function ReportVisual({ execution }: { execution: ReportExecution }) {
const visual = execution.visualization;
if (!visual || visual.kind === "table" || !visual.category || !visual.measures?.length) {
return (
<div className="reporting-visual-fallback">
{visual?.fallback_reason && <DismissibleAlert tone="info" dismissible={false}>{visual.fallback_reason}</DismissibleAlert>}
<ReportTable execution={execution} />
</div>
);
}
const measure = visual.measures[0];
const values = execution.rows.map((row) => Number(row[measure] ?? 0));
const maximum = Math.max(...values.map((value) => Math.abs(value)), 1);
return (
<div className="reporting-bar-chart" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`}>
{execution.rows.map((row, index) =>
<div className="reporting-bar-row" key={`${String(row[visual.category ?? ""])}:${index}`}>
<span>{formatValue(row[visual.category ?? ""])}</span>
<div><i style={{ width: `${Math.max(1, Math.abs(values[index]) / maximum * 100)}%` }} /></div>
<strong>{formatValue(row[measure])}</strong>
</div>
)}
<div className="reporting-chart-table"><ReportTable execution={execution} /></div>
</div>
);
}
function Inspector({ selected, execution, history, savedViews, onSelectExecution, onApplySavedView }: {
selected: ReportingDefinition | null;
execution: ReportExecution | null;
history: ReportExecution[];
savedViews: ReportingSavedView[];
onSelectExecution: (execution: ReportExecution) => void;
onApplySavedView: (view: ReportingSavedView) => void;
}) {
return (
<div className="reporting-inspector-content">
<section>
<h2><History size={16} /> Runs</h2>
{history.length === 0 && <p>No recorded runs.</p>}
{history.map((item) =>
<button type="button" key={item.execution_id} className={execution?.execution_id === item.execution_id ? "is-selected" : ""} onClick={() => onSelectExecution(item)}>
<span>{formatDateTime(item.started_at)}</span>
<StatusBadge status={item.status} label={humanize(item.status)} />
<small>{item.total_rows} rows</small>
</button>
)}
</section>
<section>
<h2><Save size={16} /> Saved views</h2>
{savedViews.length === 0 && <p>No saved views.</p>}
{savedViews.map((view) =>
<button type="button" key={view.view_id} onClick={() => onApplySavedView(view)}>
<span>{view.name}</span><small>revision {view.revision}</small>
</button>
)}
</section>
{selected &&
<section className="reporting-provenance">
<h2>Definition</h2>
<dl>
<dt>Report revision</dt><dd>{selected.revision}</dd>
<dt>Content hash</dt><dd title={selected.content_hash}>{shortHash(selected.content_hash)}</dd>
{execution && <><dt>Dataset</dt><dd>{execution.dataset_id} r{execution.dataset_revision}</dd></>}
{execution?.output_hash && <><dt>Output hash</dt><dd title={execution.output_hash}>{shortHash(execution.output_hash)}</dd></>}
{execution?.executor_version && <><dt>Executor</dt><dd>{execution.executor_version}</dd></>}
</dl>
{execution?.diagnostics.map((item, index) =>
<DismissibleAlert key={`${item.code}:${index}`} tone={item.severity === "error" ? "danger" : item.severity === "warning" ? "warning" : "info"} dismissible={false} compact>
{item.message ?? item.code ?? "Execution diagnostic"}
</DismissibleAlert>
)}
</section>
}
</div>
);
}
function SaveViewDialog({ open, onClose, onSave }: { open: boolean; onClose: () => void; onSave: (name: string) => Promise<void> }) {
const [name, setName] = useState("");
const [saving, setSaving] = useState(false);
return (
<Dialog open={open} title="Save report view" onClose={onClose} footer={<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={!name.trim() || saving} onClick={() => { setSaving(true); void onSave(name.trim()).finally(() => setSaving(false)); }}>Save</Button>
</>}>
<label className="reporting-dialog-field"><span>Name</span><input value={name} onChange={(event) => setName(event.target.value)} autoFocus /></label>
</Dialog>
);
}
function ScheduleDialog({ open, onClose, onSave }: { open: boolean; onClose: () => void; onSave: (name: string, seconds: number) => Promise<void> }) {
const [name, setName] = useState("");
const [interval, setIntervalValue] = useState("86400");
const [saving, setSaving] = useState(false);
return (
<Dialog open={open} title="Schedule report" onClose={onClose} footer={<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={!name.trim() || saving} onClick={() => { setSaving(true); void onSave(name.trim(), Number(interval)).finally(() => setSaving(false)); }}>Schedule</Button>
</>}>
<div className="reporting-dialog-grid">
<label className="reporting-dialog-field"><span>Name</span><input value={name} onChange={(event) => setName(event.target.value)} autoFocus /></label>
<label className="reporting-dialog-field"><span>Interval</span><select value={interval} onChange={(event) => setIntervalValue(event.target.value)}><option value="3600">Hourly</option><option value="86400">Daily</option><option value="604800">Weekly</option><option value="2592000">Every 30 days</option></select></label>
</div>
</Dialog>
);
}
function emptyQuery(): ReportingQuery {
return { mode: "summary", dimensions: [], measures: [], filters: [], sort: [], pivot: null, offset: 0, limit: 200 };
}
function normalizeQuery(value: ReportingQuery): ReportingQuery {
return { ...emptyQuery(), ...value, dimensions: [...(value.dimensions ?? [])], measures: [...(value.measures ?? [])], filters: [...(value.filters ?? [])], sort: [...(value.sort ?? [])] };
}
function defaultParameters(parameters: Array<{ key: string; default?: unknown }>): Record<string, unknown> {
return Object.fromEntries(parameters.filter((item) => item.default !== null && item.default !== undefined).map((item) => [item.key, item.default]));
}
function toggle(values: string[], value: string): string[] {
return values.includes(value) ? values.filter((item) => item !== value) : [...values, value];
}
function typedValue(value: string, type: string): unknown {
if (value === "") return null;
if (type === "integer") return Number.parseInt(value, 10);
if (type === "number") return Number(value);
return value;
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "number") return new Intl.NumberFormat().format(value);
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function formatDateTime(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
}
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 : "Reporting could not complete the request.";
}