Complete governed reporting execution and publication
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user