Complete governed reporting execution and publication

This commit is contained in:
2026-08-04 02:23:24 +02:00
parent a81151391b
commit 8337aec19a
29 changed files with 3611 additions and 81 deletions
+146
View File
@@ -103,9 +103,69 @@ export type ReportExecution = {
kind: string;
requested_kind?: string;
category?: string | null;
series?: string | null;
measures?: string[];
options?: Record<string, unknown>;
fallback_reason?: string | null;
};
delivery_authorization?: Record<string, unknown>;
};
export type ReportingDrillContext = {
token: string;
drill_context_id: string;
execution_id: string;
dimension_path: Array<{ dimension: string; label: string; value: unknown }>;
expires_at: string;
};
export type ReportingDrillResult = Omit<ReportingDrillContext, "token"> & {
rows: Array<Record<string, unknown>>;
schema: Array<{ name: string; type: string }>;
total_rows: number;
truncated: boolean;
source_fingerprints: Array<Record<string, unknown>>;
policy_provenance: Record<string, unknown>;
};
export type ReportingSchedule = {
schedule_id: string;
report_id: string;
report_revision: number;
name: string;
revision: number;
trigger_kind: "scheduled" | "interval";
trigger_config: Record<string, unknown>;
parameters: Record<string, unknown>;
query: ReportingQuery;
publication_target: Record<string, unknown>;
enabled: boolean;
next_run_at?: string | null;
last_run_at?: string | null;
last_execution_id?: string | null;
};
export type ReportingPublicationTarget = {
capability: string;
label: string;
available: boolean;
reason?: string | null;
formats: string[];
target_label: string;
target_required: boolean;
required_options: string[];
};
export type ReportingPublication = {
publication_id: string;
execution_id: string;
target_capability: string;
target_ref?: string | null;
format: string;
status: string;
evidence: Record<string, unknown>;
error?: string | null;
completed_at?: string | null;
};
export type ReportingSavedView = {
@@ -307,6 +367,26 @@ export function listExecutions(
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(reportId)}/executions?limit=30`, { signal });
}
export function createDrillContext(
settings: ApiSettings,
executionId: string,
aggregateRow: Record<string, unknown>,
limit = 200
): Promise<ReportingDrillContext> {
return apiFetch(settings, `/api/v1/reporting/executions/${encodeURIComponent(executionId)}/drill-contexts`, {
method: "POST",
body: JSON.stringify({ aggregate_row: aggregateRow, limit })
});
}
export function resolveDrillContext(
settings: ApiSettings,
token: string,
signal?: AbortSignal
): Promise<ReportingDrillResult> {
return apiFetch(settings, `/api/v1/reporting/drill-contexts/${encodeURIComponent(token)}`, { signal });
}
export function listSavedViews(
settings: ApiSettings,
reportId: string,
@@ -364,6 +444,72 @@ export function createIntervalSchedule(
});
}
export function listSchedules(
settings: ApiSettings,
reportId: string,
signal?: AbortSignal
): Promise<{ schedules: ReportingSchedule[] }> {
return apiFetch(settings, apiPath("/api/v1/reporting/schedules", { report_id: reportId }), { signal });
}
export function updateSchedule(
settings: ApiSettings,
schedule: ReportingSchedule,
changes: Partial<Pick<ReportingSchedule, "enabled" | "name">>
): Promise<ReportingSchedule> {
return apiFetch(settings, `/api/v1/reporting/schedules/${encodeURIComponent(schedule.schedule_id)}`, {
method: "PUT",
body: JSON.stringify({
schedule_id: schedule.schedule_id,
report_id: schedule.report_id,
report_revision: schedule.report_revision,
name: changes.name ?? schedule.name,
trigger_kind: schedule.trigger_kind,
trigger_config: schedule.trigger_config,
parameters: schedule.parameters,
query: schedule.query,
publication_target: schedule.publication_target,
enabled: changes.enabled ?? schedule.enabled,
next_run_at: schedule.next_run_at ?? null,
expected_revision: schedule.revision
})
});
}
export function listPublicationTargets(
settings: ApiSettings,
signal?: AbortSignal
): Promise<{ targets: ReportingPublicationTarget[] }> {
return apiFetch(settings, "/api/v1/reporting/publication-targets", { signal });
}
export function listPublications(
settings: ApiSettings,
executionId: string,
signal?: AbortSignal
): Promise<{ publications: ReportingPublication[] }> {
return apiFetch(settings, apiPath("/api/v1/reporting/publications", { execution_id: executionId }), { signal });
}
export function publishExecution(
settings: ApiSettings,
executionId: string,
request: {
target_capability: string;
target_ref?: string | null;
format: string;
options: Record<string, unknown>;
}
): Promise<ReportingPublication> {
return apiFetch(settings, `/api/v1/reporting/executions/${encodeURIComponent(executionId)}/publications`, {
method: "POST",
body: JSON.stringify({
...request,
idempotency_key: crypto.randomUUID()
})
});
}
export async function downloadExecution(
settings: ApiSettings,
executionId: string,
+426 -24
View File
@@ -1,8 +1,10 @@
import {
BarChart3,
CalendarClock,
ChevronRight,
Download,
FileJson,
FolderOutput,
History,
Play,
RefreshCw,
@@ -28,27 +30,39 @@ import {
PageScrollViewport,
SegmentedControl,
StatusBadge,
ToggleSwitch,
hasScope,
type DataGridColumn,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
createIntervalSchedule,
createDrillContext,
downloadExecution,
getDefinition,
listDefinitions,
listExecutions,
listPublicationTargets,
listPublications,
listProviderReports,
listSavedViews,
listSchedules,
publishExecution,
reportPayload,
runReport,
saveView,
semanticPayload,
resolveDrillContext,
updateSchedule,
type ReportExecution,
type ReportingDrillResult,
type ReportingDefinition,
type ReportingQuery,
type ReportingQueryMode,
type ReportingPublication,
type ReportingPublicationTarget,
type ReportingSavedView,
type ReportingSchedule,
type ProviderReportDescriptor,
type SemanticModelPayload
} from "../../api/reporting";
@@ -73,14 +87,22 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
const [execution, setExecution] = useState<ReportExecution | null>(null);
const [history, setHistory] = useState<ReportExecution[]>([]);
const [savedViews, setSavedViews] = useState<ReportingSavedView[]>([]);
const [schedules, setSchedules] = useState<ReportingSchedule[]>([]);
const [publicationTargets, setPublicationTargets] = useState<ReportingPublicationTarget[]>([]);
const [publications, setPublications] = useState<ReportingPublication[]>([]);
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 [publishDialogOpen, setPublishDialogOpen] = useState(false);
const [drillDialogOpen, setDrillDialogOpen] = useState(false);
const [drillResult, setDrillResult] = useState<ReportingDrillResult | null>(null);
const [drilling, setDrilling] = useState(false);
const canRun = hasScope(auth, "reporting:report:run");
const canSchedule = hasScope(auth, "reporting:schedule:write");
const canPublish = hasScope(auth, "reporting:report:publish");
const selected = useMemo(
() => reports.find((item) => item.definition_id === selectedId) ?? null,
@@ -102,15 +124,17 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
query: submittedSearch,
limit: 200
}, signal),
listProviderReports(settings, signal)
listProviderReports(settings, signal),
canPublish ? listPublicationTargets(settings, signal) : Promise.resolve({ targets: [] })
]).
then(([result, providerResult]) => {
then(([result, providerResult, targetResult]) => {
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);
setProviderReports(providerRows);
setPublicationTargets(targetResult.targets);
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) {
@@ -141,6 +165,7 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
setExecution(null);
setHistory([]);
setSavedViews([]);
setSchedules([]);
return;
}
const controller = new AbortController();
@@ -150,12 +175,14 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
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)
listSavedViews(settings, selected.definition_id, controller.signal),
canSchedule ? listSchedules(settings, selected.definition_id, controller.signal) : Promise.resolve({ schedules: [] })
]).
then(([semanticDefinition, executions, views]) => {
then(([semanticDefinition, executions, views, scheduleResult]) => {
setSemantic(semanticPayload(semanticDefinition));
setHistory(executions.executions);
setSavedViews(views.views);
setSchedules(scheduleResult.schedules);
setExecution(executions.executions.find((item) => item.status === "succeeded") ?? null);
}).
catch((reason) => {
@@ -164,6 +191,20 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
return () => controller.abort();
}, [settings, selectedId]);
useEffect(() => {
if (!execution || !canPublish) {
setPublications([]);
return;
}
const controller = new AbortController();
void listPublications(settings, execution.execution_id, controller.signal).
then((result) => setPublications(result.publications)).
catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason));
});
return () => controller.abort();
}, [settings, execution?.execution_id, canPublish]);
function submitSearch(event: FormEvent) {
event.preventDefault();
setSubmittedSearch(search.trim());
@@ -189,6 +230,23 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
if (view.state.query) setQuery(normalizeQuery(view.state.query));
}
async function drill(row: Record<string, unknown>) {
if (!execution) return;
setDrillDialogOpen(true);
setDrillResult(null);
setDrilling(true);
setError("");
try {
const context = await createDrillContext(settings, execution.execution_id, row);
setDrillResult(await resolveDrillContext(settings, context.token));
} catch (reason) {
setError(message(reason));
setDrillDialogOpen(false);
} finally {
setDrilling(false);
}
}
return (
<main className="reporting-page">
<div className="reporting-shell">
@@ -302,13 +360,16 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
<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)))} />
{canPublish &&
<IconButton label="Publish report" icon={<FolderOutput size={17} />} variant="ghost" onClick={() => setPublishDialogOpen(true)} />
}
</>
}
</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} />}
{execution && outputMode === "visual" && <ReportVisual execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
{execution && outputMode === "table" && <ReportTable execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
</div>
</> :
<div className="reporting-empty">Select a report.</div>
@@ -322,8 +383,18 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
execution={execution}
history={history}
savedViews={savedViews}
schedules={schedules}
publications={publications}
onSelectExecution={setExecution}
onApplySavedView={applySavedView}
onScheduleEnabledChange={async (schedule, enabled) => {
try {
const updated = await updateSchedule(settings, schedule, { enabled });
setSchedules((current) => current.map((item) => item.schedule_id === updated.schedule_id ? updated : item));
} catch (reason) {
setError(message(reason));
}
}}
/>}
</PageScrollViewport>
</div>
@@ -343,10 +414,28 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
onClose={() => setScheduleDialogOpen(false)}
onSave={async (name, seconds) => {
if (!selected) return;
await createIntervalSchedule(settings, selected, name, seconds, query, parameters);
const created = await createIntervalSchedule(settings, selected, name, seconds, query, parameters) as ReportingSchedule;
setSchedules((current) => [...current, created].sort((left, right) => left.name.localeCompare(right.name)));
setScheduleDialogOpen(false);
}}
/>
<PublishDialog
open={publishDialogOpen}
targets={publicationTargets}
onClose={() => setPublishDialogOpen(false)}
onPublish={async (request) => {
if (!execution) return;
const publication = await publishExecution(settings, execution.execution_id, request);
setPublications((current) => [publication, ...current]);
setPublishDialogOpen(false);
}}
/>
<DrillDialog
open={drillDialogOpen}
loading={drilling}
result={drillResult}
onClose={() => setDrillDialogOpen(false)}
/>
</main>
);
}
@@ -446,11 +535,11 @@ function QueryControls({ query, semantic, parameters, parameterValues, onQueryCh
);
}
function ReportTable({ execution }: { execution: ReportExecution }) {
function ReportTable({ execution, onDrill }: { execution: ReportExecution; onDrill?: (row: Record<string, unknown>) => void }) {
const [page, setPage] = useState(0);
useEffect(() => setPage(0), [execution.execution_id]);
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() =>
execution.schema.map((field) => ({
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => {
const result = execution.schema.map((field) => ({
id: field.name,
header: humanize(field.name),
width: "1fr",
@@ -461,7 +550,26 @@ function ReportTable({ execution }: { execution: ReportExecution }) {
filterType: field.type === "integer" || field.type === "number" ? field.type : "text",
value: (row) => row[field.name],
render: (row) => formatValue(row[field.name])
})), [execution]);
} satisfies DataGridColumn<Record<string, unknown>>));
if (onDrill) {
result.push({
id: "drill",
header: "Detail",
width: 74,
minWidth: 74,
maxWidth: 74,
render: (row) => (
<IconButton
label="Show authorized contributing rows"
icon={<ChevronRight size={16} />}
variant="ghost"
onClick={() => onDrill(row)}
/>
)
});
}
return result;
}, [execution, onDrill]);
return (
<DataGrid
id={`reporting-execution-${execution.execution_id}`}
@@ -476,40 +584,268 @@ function ReportTable({ execution }: { execution: ReportExecution }) {
);
}
function ReportVisual({ execution }: { execution: ReportExecution }) {
function ReportVisual({ execution, onDrill }: { execution: ReportExecution; onDrill?: (row: Record<string, unknown>) => void }) {
const visual = execution.visualization;
if (!visual || visual.kind === "table" || !visual.category || !visual.measures?.length) {
const needsCategory = visual?.kind !== "metric";
if (!visual || visual.kind === "table" || !visual.measures?.length || (needsCategory && !visual.category)) {
return (
<div className="reporting-visual-fallback">
{visual?.fallback_reason && <DismissibleAlert tone="info" dismissible={false}>{visual.fallback_reason}</DismissibleAlert>}
<ReportTable execution={execution} />
<ReportTable execution={execution} onDrill={onDrill} />
</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>
if (visual.kind === "metric") {
return (
<div className="reporting-metric-grid">
{visual.measures.map((key) =>
<div className="reporting-metric" key={key}>
<span>{humanize(key)}</span>
<strong>{formatValue(execution.rows[0]?.[key])}</strong>
</div>
)}
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
</div>
);
}
if (visual.kind === "column") {
return (
<div className="reporting-chart-stack">
<div className="reporting-column-chart" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`}>
{execution.rows.slice(0, 50).map((row, index) =>
<div className="reporting-column" key={`${String(row[visual.category ?? ""])}:${index}`}>
<strong>{formatValue(row[measure])}</strong>
<i style={{ height: `${Math.max(2, Math.abs(values[index]) / maximum * 100)}%` }} />
<span>{formatValue(row[visual.category ?? ""])}</span>
</div>
)}
</div>
)}
<div className="reporting-chart-table"><ReportTable execution={execution} /></div>
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
</div>
);
}
if (visual.kind === "line" || visual.kind === "area") {
const points = chartPoints(values.slice(0, 50), 700, 250);
return (
<div className="reporting-line-chart">
<svg viewBox="0 0 700 250" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`} preserveAspectRatio="none">
{visual.kind === "area" && <polygon points={`0,250 ${points} 700,250`} className="reporting-chart-area" />}
<polyline points={points} className="reporting-chart-line" />
</svg>
<div className="reporting-chart-labels">
{execution.rows.slice(0, 50).map((row, index) => <span key={index}>{formatValue(row[visual.category ?? ""])}</span>)}
</div>
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
</div>
);
}
if (visual.kind === "pie" || visual.kind === "donut") {
const positive = values.map((value) => Math.max(0, value));
const total = positive.reduce((sum, value) => sum + value, 0) || 1;
const stops = pieStops(positive, total);
return (
<div className="reporting-pie-layout">
<div className={`reporting-pie${visual.kind === "donut" ? " is-donut" : ""}`} style={{ background: `conic-gradient(${stops})` }} role="img" aria-label={`${humanize(measure)} distribution`} />
<ol>
{execution.rows.slice(0, 12).map((row, index) => <li key={index}><i className={`reporting-swatch reporting-swatch-${index % 8}`} /><span>{formatValue(row[visual.category ?? ""])}</span><strong>{formatValue(row[measure])}</strong></li>)}
</ol>
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
</div>
);
}
return (
<div className="reporting-chart-stack">
<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>
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
</div>
);
}
function Inspector({ selected, execution, history, savedViews, onSelectExecution, onApplySavedView }: {
function AccessExplanation({ execution }: { execution: ReportExecution }) {
const provenance = objectValue(execution.provenance);
const explanation = objectValue(provenance.access_explanation);
const hiddenDimensions = stringValues(explanation.hidden_dimensions);
const hiddenMeasures = stringValues(explanation.hidden_measures);
const disabledActions = stringValues(explanation.disabled_actions);
const hiddenRows = Number(explanation.hidden_rows ?? 0);
const reasons = objectValue(explanation.reasons);
if (!hiddenDimensions.length && !hiddenMeasures.length && !disabledActions.length && hiddenRows <= 0) {
return <DismissibleAlert tone="info" dismissible={false} compact>No report fields, rows, or actions were hidden by effective policy.</DismissibleAlert>;
}
return (
<div className="reporting-access-explanation">
<strong>Effective access</strong>
{hiddenDimensions.length > 0 && <span>Hidden dimensions: {hiddenDimensions.join(", ")}</span>}
{hiddenMeasures.length > 0 && <span>Hidden measures: {hiddenMeasures.join(", ")}</span>}
{hiddenRows > 0 && <span>{hiddenRows} source rows were removed before planning.</span>}
{disabledActions.map((action) => <span key={action}>{String(reasons[action] ?? `The ${action} action is disabled by policy.`)}</span>)}
</div>
);
}
function PublishDialog({ open, targets, onClose, onPublish }: {
open: boolean;
targets: ReportingPublicationTarget[];
onClose: () => void;
onPublish: (request: { target_capability: string; target_ref?: string | null; format: string; options: Record<string, unknown> }) => Promise<void>;
}) {
const firstAvailable = targets.find((item) => item.available) ?? targets[0];
const [targetCapability, setTargetCapability] = useState(firstAvailable?.capability ?? "");
const [targetRef, setTargetRef] = useState("");
const [format, setFormat] = useState(firstAvailable?.formats[0] ?? "csv");
const [filename, setFilename] = useState("");
const [mailProfileId, setMailProfileId] = useState("");
const [fromAddress, setFromAddress] = useState("");
const [subject, setSubject] = useState("");
const [saving, setSaving] = useState(false);
const [dialogError, setDialogError] = useState("");
const target = targets.find((item) => item.capability === targetCapability) ?? firstAvailable;
useEffect(() => {
if (!open) return;
const next = targets.find((item) => item.available) ?? targets[0];
setTargetCapability(next?.capability ?? "");
setFormat(next?.formats[0] ?? "csv");
setTargetRef("");
setFilename("");
setMailProfileId("");
setFromAddress("");
setSubject("");
setDialogError("");
}, [open, targets]);
const mailTarget = target?.capability.endsWith(".mail") === true;
const valid = Boolean(target?.available) && (!target?.target_required || targetRef.trim()) && (!mailTarget || (mailProfileId.trim() && fromAddress.trim()));
return (
<Dialog
open={open}
title="Publish report"
onClose={onClose}
footer={<>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="primary"
disabled={!valid || saving}
disabledReason={!target?.available ? target?.reason ?? "The selected target is unavailable." : undefined}
onClick={() => {
if (!target) return;
setSaving(true);
setDialogError("");
void onPublish({
target_capability: target.capability,
target_ref: targetRef.trim() || null,
format,
options: mailTarget ? {
mail_profile_id: mailProfileId.trim(),
from_address: fromAddress.trim(),
subject: subject.trim() || undefined
} : { filename: filename.trim() || undefined }
}).catch((reason) => setDialogError(message(reason))).finally(() => setSaving(false));
}}>
Publish
</Button>
</>}>
{dialogError && <DismissibleAlert tone="danger" resetKey={dialogError}>{dialogError}</DismissibleAlert>}
<div className="reporting-dialog-grid">
<label className="reporting-dialog-field">
<span>Target</span>
<select value={targetCapability} onChange={(event) => {
const next = targets.find((item) => item.capability === event.target.value);
setTargetCapability(event.target.value);
setFormat(next?.formats[0] ?? "csv");
}}>
{targets.map((item) => <option key={item.capability} value={item.capability}>{item.label}{item.available ? "" : " (unavailable)"}</option>)}
</select>
</label>
<label className="reporting-dialog-field">
<span>Format</span>
<select value={format} onChange={(event) => setFormat(event.target.value)} disabled={!target?.available}>
{(target?.formats ?? []).map((item) => <option value={item} key={item}>{item.toUpperCase()}</option>)}
</select>
</label>
{target && <label className="reporting-dialog-field">
<span>{target.target_label}</span>
<input value={targetRef} onChange={(event) => setTargetRef(event.target.value)} placeholder={mailTarget ? "recipient@example.org" : "Generated/Reports"} />
</label>}
{!mailTarget && <label className="reporting-dialog-field"><span>Filename</span><input value={filename} onChange={(event) => setFilename(event.target.value)} placeholder="Generated from report name" /></label>}
{mailTarget && <>
<label className="reporting-dialog-field"><span>Mail profile ID</span><input value={mailProfileId} onChange={(event) => setMailProfileId(event.target.value)} /></label>
<label className="reporting-dialog-field"><span>Sender address</span><input type="email" value={fromAddress} onChange={(event) => setFromAddress(event.target.value)} /></label>
<label className="reporting-dialog-field reporting-dialog-span"><span>Subject</span><input value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Generated from report name" /></label>
</>}
</div>
{target?.reason && <DismissibleAlert tone="warning" dismissible={false}>{target.reason}</DismissibleAlert>}
</Dialog>
);
}
function DrillDialog({ open, loading, result, onClose }: {
open: boolean;
loading: boolean;
result: ReportingDrillResult | null;
onClose: () => void;
}) {
const [page, setPage] = useState(0);
useEffect(() => setPage(0), [result?.drill_context_id]);
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() =>
(result?.schema ?? []).map((field) => ({
id: field.name,
header: humanize(field.name),
width: "1fr",
minWidth: 120,
resizable: true,
sortable: true,
filterable: true,
filterType: field.type === "number" || field.type === "integer" ? field.type : "text",
value: (row) => row[field.name],
render: (row) => formatValue(row[field.name])
})), [result]);
return (
<Dialog open={open} title="Authorized contributing rows" onClose={onClose} className="reporting-drill-dialog" footer={<Button onClick={onClose}>Close</Button>}>
{loading && <LoadingIndicator label="Rechecking access and loading detail rows" />}
{result && <>
<nav className="reporting-drill-path" aria-label="Drill-through filter path">
{result.dimension_path.map((item, index) => <span key={`${item.dimension}:${index}`}><strong>{item.label}</strong> = {formatValue(item.value)}</span>)}
</nav>
<div className="reporting-drill-grid">
<DataGrid
id={`reporting-drill-${result.drill_context_id}`}
rows={result.rows}
columns={columns}
getRowKey={(_row, index) => `${result.drill_context_id}:${index}`}
initialFit="container"
resizeBehavior="cover"
emptyText="No contributing rows are authorized."
pagination={{ page, pageSize: 50, onPageChange: setPage }}
/>
</div>
<small>{result.total_rows} authorized rows{result.truncated ? " (bounded result)" : ""}. Access and source fingerprints were rechecked for this drill.</small>
</>}
</Dialog>
);
}
function Inspector({ selected, execution, history, savedViews, schedules, publications, onSelectExecution, onApplySavedView, onScheduleEnabledChange }: {
selected: ReportingDefinition | null;
execution: ReportExecution | null;
history: ReportExecution[];
savedViews: ReportingSavedView[];
schedules: ReportingSchedule[];
publications: ReportingPublication[];
onSelectExecution: (execution: ReportExecution) => void;
onApplySavedView: (view: ReportingSavedView) => void;
onScheduleEnabledChange: (schedule: ReportingSchedule, enabled: boolean) => void;
}) {
return (
<div className="reporting-inspector-content">
@@ -524,6 +860,29 @@ function Inspector({ selected, execution, history, savedViews, onSelectExecution
</button>
)}
</section>
{schedules.length > 0 &&
<section>
<h2><CalendarClock size={16} /> Schedules</h2>
{schedules.map((schedule) =>
<div className="reporting-inspector-toggle" key={schedule.schedule_id}>
<span><strong>{schedule.name}</strong><small>{schedule.trigger_kind === "interval" ? `Every ${formatInterval(schedule.trigger_config.seconds)}` : "Scheduled"}</small></span>
<ToggleSwitch label="Enabled" checked={schedule.enabled} onChange={(enabled) => onScheduleEnabledChange(schedule, enabled)} />
</div>
)}
</section>
}
{publications.length > 0 &&
<section>
<h2><FolderOutput size={16} /> Publications</h2>
{publications.map((publication) =>
<div className="reporting-inspector-record" key={publication.publication_id}>
<span>{humanize(publication.target_capability.split(".").at(-1) ?? "target")}</span>
<StatusBadge status={publication.status} label={humanize(publication.status)} />
<small>{publication.completed_at ? formatDateTime(publication.completed_at) : "Pending"}</small>
</div>
)}
</section>
}
<section>
<h2><Save size={16} /> Saved views</h2>
{savedViews.length === 0 && <p>No saved views.</p>}
@@ -548,6 +907,7 @@ function Inspector({ selected, execution, history, savedViews, onSelectExecution
{item.message ?? item.code ?? "Execution diagnostic"}
</DismissibleAlert>
)}
{execution && <AccessExplanation execution={execution} />}
</section>
}
</div>
@@ -619,6 +979,48 @@ function formatDateTime(value: string): string {
return Number.isNaN(parsed.valueOf()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
}
function formatInterval(value: unknown): string {
const seconds = Number(value);
if (seconds === 3600) return "hour";
if (seconds === 86400) return "day";
if (seconds === 604800) return "week";
if (seconds === 2592000) return "30 days";
return `${Number.isFinite(seconds) ? seconds : 0} seconds`;
}
function chartPoints(values: number[], width: number, height: number): string {
if (!values.length) return "";
const finite = values.map((value) => Number.isFinite(value) ? value : 0);
const minimum = Math.min(...finite);
const maximum = Math.max(...finite);
const range = maximum - minimum || 1;
const divisor = Math.max(1, finite.length - 1);
return finite.map((value, index) => {
const x = index / divisor * width;
const y = height - ((value - minimum) / range * (height - 20) + 10);
return `${x.toFixed(2)},${y.toFixed(2)}`;
}).join(" ");
}
const PIE_COLORS = ["#2f7d6e", "#3366a8", "#c28b2c", "#9a4f71", "#5f7f3a", "#b85c3b", "#586176", "#2e8b9a"];
function pieStops(values: number[], total: number): string {
let offset = 0;
return values.slice(0, 12).map((value, index) => {
const start = offset;
offset += value / total * 100;
return `${PIE_COLORS[index % PIE_COLORS.length]} ${start.toFixed(2)}% ${offset.toFixed(2)}%`;
}).join(", ");
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function stringValues(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function humanize(value: string): string {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
@@ -0,0 +1,57 @@
import { useCallback } from "react";
import { BarChart3 } from "lucide-react";
import { Link } from "react-router";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
StatusBadge,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import { listDefinitions } from "../../api/reporting";
export default function ReportingReportsWidget({ settings, refreshKey, configuration }: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = boundedNumber(configuration.maxItems, 5, 1, 12);
const load = useCallback(async () => {
const result = await listDefinitions(settings, {
kinds: ["report"],
status: ["active"],
limit: maxItems
});
return result.definitions.slice(0, maxItems);
}, [maxItems, settings]);
const { data, loading, error } = useDashboardWidgetData(load, refreshKey);
return (
<LoadingFrame loading={loading} label="Loading reports">
{error && <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>}
<DashboardWidgetList
emptyText="No active reports are available."
items={(data ?? []).map((report) => ({
id: report.definition_id,
title: report.name,
detail: report.description || report.definition_key,
meta: `Revision ${report.revision}`,
leading: <BarChart3 size={17} aria-hidden="true" />,
trailing: <StatusBadge status={report.status} label={report.status} />,
to: "/reports"
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/reports">Open reporting</Link>
</div>
</LoadingFrame>
);
}
function boundedNumber(value: unknown, fallback: number, minimum: number, maximum: number): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric) ? Math.max(minimum, Math.min(maximum, Math.round(numeric))) : fallback;
}
+43 -3
View File
@@ -1,10 +1,46 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import type { DashboardWidgetsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import ReportingReportsWidget from "./features/reporting/ReportingReportsWidget";
import "./styles/reporting.css";
const ReportingPage = lazy(() => import("./features/reporting/ReportingPage"));
const reportingDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "reporting.reports",
surfaceId: "reporting.widget.reports",
title: "Reports",
description: "Active governed reports available in the current scope.",
moduleId: "reporting",
category: "Analysis",
order: 75,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: ["reporting:definition:read"],
refreshIntervalMs: 60_000,
defaultConfiguration: { maxItems: 5 },
configurationFields: [
{
id: "maxItems",
label: "Maximum reports",
kind: "number",
min: 1,
max: 12,
step: 1,
required: true
}
],
render: ({ settings, refreshKey, configuration }) => createElement(
ReportingReportsWidget,
{ settings, refreshKey, configuration }
)
}
]
};
export const reportingModule: PlatformWebModule = {
id: "reporting",
label: "Reporting",
@@ -52,8 +88,12 @@ export const reportingModule: PlatformWebModule = {
{ id: "reporting.navigation", moduleId: "reporting", kind: "navigation", label: "Reporting navigation", order: 10 },
{ id: "reporting.workspace", moduleId: "reporting", kind: "route", label: "Reporting workspace", order: 20 },
{ id: "reporting.parameters", moduleId: "reporting", kind: "section", label: "Report parameters and filters", parentId: "reporting.workspace", order: 30 },
{ id: "reporting.results", moduleId: "reporting", kind: "section", label: "Authorized report results", parentId: "reporting.workspace", order: 40 }
]
{ id: "reporting.results", moduleId: "reporting", kind: "section", label: "Authorized report results", parentId: "reporting.workspace", order: 40 },
{ id: "reporting.widget.reports", moduleId: "reporting", kind: "section", label: "Reports dashboard widget", order: 75 }
],
uiCapabilities: {
"dashboard.widgets": reportingDashboardWidgets
}
};
export default reportingModule;
+222
View File
@@ -361,6 +361,152 @@
padding: 10px;
}
.reporting-chart-stack,
.reporting-line-chart,
.reporting-metric-grid,
.reporting-pie-layout {
display: grid;
gap: 14px;
min-width: 0;
}
.reporting-column-chart {
display: flex;
align-items: end;
gap: 8px;
min-height: 280px;
padding: 18px 12px 0;
overflow-x: auto;
border-bottom: 1px solid var(--border);
}
.reporting-column {
display: grid;
grid-template-rows: 24px minmax(180px, 1fr) 32px;
align-items: end;
min-width: 54px;
flex: 1 0 54px;
gap: 4px;
text-align: center;
}
.reporting-column strong,
.reporting-column span {
overflow: hidden;
font-size: 0.72rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.reporting-column i {
display: block;
width: 72%;
margin: 0 auto;
border-radius: 3px 3px 0 0;
background: var(--accent);
}
.reporting-line-chart svg {
width: 100%;
height: 280px;
overflow: visible;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-raised);
}
.reporting-chart-line {
fill: none;
stroke: var(--accent);
stroke-width: 3;
vector-effect: non-scaling-stroke;
}
.reporting-chart-area {
fill: color-mix(in srgb, var(--accent) 28%, transparent);
}
.reporting-chart-labels {
display: flex;
justify-content: space-between;
gap: 8px;
overflow-x: auto;
color: var(--text-soft);
font-size: 0.7rem;
}
.reporting-metric-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.reporting-metric {
display: flex;
min-height: 92px;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-raised);
}
.reporting-metric span {
color: var(--text-soft);
font-size: 0.76rem;
}
.reporting-metric strong {
font-size: 1.45rem;
}
.reporting-pie-layout {
grid-template-columns: minmax(180px, 300px) minmax(240px, 1fr);
align-items: center;
}
.reporting-pie {
width: min(100%, 280px);
aspect-ratio: 1;
margin: 0 auto;
border-radius: 50%;
box-shadow: inset 0 0 0 1px var(--border);
}
.reporting-pie.is-donut {
border: 58px solid var(--surface-raised);
}
.reporting-pie-layout ol {
display: grid;
gap: 7px;
margin: 0;
padding: 0;
list-style: none;
}
.reporting-pie-layout li {
display: grid;
grid-template-columns: 12px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.reporting-swatch {
width: 10px;
height: 10px;
border-radius: 2px;
background: #2f7d6e;
}
.reporting-swatch-1 { background: #3366a8; }
.reporting-swatch-2 { background: #c28b2c; }
.reporting-swatch-3 { background: #9a4f71; }
.reporting-swatch-4 { background: #5f7f3a; }
.reporting-swatch-5 { background: #b85c3b; }
.reporting-swatch-6 { background: #586176; }
.reporting-swatch-7 { background: #2e8b9a; }
.reporting-bar-row {
display: grid;
grid-template-columns: minmax(100px, 22%) minmax(180px, 1fr) minmax(80px, auto);
@@ -384,11 +530,87 @@
}
.reporting-chart-table {
grid-column: 1 / -1;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.reporting-inspector-toggle,
.reporting-inspector-record {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 6px 10px;
padding: 9px 10px;
border-bottom: 1px solid var(--border);
}
.reporting-inspector-toggle > span,
.reporting-inspector-record > span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.reporting-inspector-toggle small,
.reporting-inspector-record small {
grid-column: 1 / -1;
}
.reporting-inspector-toggle .toggle-switch-copy {
display: none;
}
.reporting-access-explanation {
display: flex;
flex-direction: column;
gap: 4px;
margin: 8px;
padding: 9px 10px;
border-left: 3px solid var(--accent);
background: var(--hover-bg);
font-size: 0.76rem;
}
.reporting-dialog-span {
grid-column: 1 / -1;
}
.reporting-drill-dialog {
width: min(1100px, calc(100vw - 32px));
height: min(760px, calc(100vh - 32px));
}
.reporting-drill-dialog .dialog-body {
display: flex;
min-height: 0;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
.reporting-drill-path {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.reporting-drill-path span {
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-subtle, var(--surface));
font-size: 0.76rem;
}
.reporting-drill-grid {
min-height: 0;
flex: 1;
overflow: auto;
}
.reporting-dialog-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));