feat: harden governed runs and add dashboard integration

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent 8c2b6035cc
commit 09c98087c5
10 changed files with 372 additions and 29 deletions
+1 -1
View File
@@ -23,7 +23,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
+17 -1
View File
@@ -1,4 +1,9 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
import {
apiFetch,
apiReferenceOptionProvider,
type ApiSettings,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import type { DefinitionGraphNodeType } from "@govoplan/core-webui/definition-graph";
export type PipelineStatus = "draft" | "active" | "archived";
@@ -376,6 +381,17 @@ export function deriveDataflowPipeline(
);
}
export function dataflowScopeReferenceProvider(
settings: ApiSettings,
scopeType: "user" | "group"
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
"/api/v1/dataflow/scope-targets",
{ scope_type: scopeType }
);
}
export async function listDataflowTriggers(
settings: ApiSettings,
pipelineId: string
+76 -21
View File
@@ -31,6 +31,7 @@ import {
FormField,
IconButton,
LoadingFrame,
ReferenceSelect,
SegmentedControl,
StatusBadge,
ToggleSwitch,
@@ -49,6 +50,7 @@ import {
createDataflowSourceSnapshot,
deleteDataflowPipeline,
deleteDataflowTrigger,
dataflowScopeReferenceProvider,
deriveDataflowPipeline,
listDataflowNodeTypes,
listDataflowPipelineRuns,
@@ -939,6 +941,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
/>
<DefinitionSettingsDialog
open={definitionSettingsOpen}
settings={settings}
draft={draft}
editable={canEdit}
onChange={updateDraft}
@@ -983,18 +986,30 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
function DefinitionSettingsDialog({
open,
settings,
draft,
editable,
onChange,
onClose
}: {
open: boolean;
settings: ApiSettings;
draft: PipelineDraft | null;
editable: boolean;
onChange: (patch: Partial<PipelineDraft>) => void;
onClose: () => void;
}) {
const provenance = draft?.governance?.actions.edit?.source_path ?? [];
const referenceScopeType = draft?.scopeType === "group" ? "group" : "user";
const scopeProvider = useMemo(
() => dataflowScopeReferenceProvider(settings, referenceScopeType),
[
referenceScopeType,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]
);
return (
<Dialog
open={open}
@@ -1023,23 +1038,34 @@ function DefinitionSettingsDialog({
<option value="user">User</option>
</select>
</FormField>
<FormField label="Scope ID">
<input
value={draft.scopeId}
disabled={
!editable
|| Boolean(draft.id)
|| draft.scopeType === "system"
|| draft.scopeType === "tenant"
}
placeholder={
{draft.scopeType === "user" || draft.scopeType === "group" ? (
<FormField
label={draft.scopeType === "user" ? "User" : "Group"}
help={
draft.scopeType === "user"
? "Current user when empty"
: "Required for group"
? "The stable account ID is stored; directory labels are presentation-only."
: "Only groups available in the active tenant can be selected."
}
onChange={(event) => onChange({ scopeId: event.target.value })}
/>
</FormField>
>
<ReferenceSelect
value={draft.scopeId}
onChange={(value) => onChange({ scopeId: value })}
provider={scopeProvider}
aria-label={
draft.scopeType === "user"
? "Definition user"
: "Definition group"
}
placeholder={
draft.scopeType === "user"
? "Select a user"
: "Select a group"
}
disabled={!editable || Boolean(draft.id)}
required
/>
</FormField>
) : null}
<FormField
label="Definition kind"
help="Templates can be reused or derived, but cannot be run or automated directly."
@@ -1131,6 +1157,19 @@ function DerivePipelineDialog({
const [scopeId, setScopeId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const scopeProvider = useMemo(
() =>
dataflowScopeReferenceProvider(
settings,
scopeType === "group" ? "group" : "user"
),
[
scopeType,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]
);
useEffect(() => {
if (!open) return;
@@ -1177,7 +1216,12 @@ function DerivePipelineDialog({
<Button
variant="primary"
onClick={() => void derive()}
disabled={busy || !pipeline || !name.trim()}
disabled={
busy
|| !pipeline
|| !name.trim()
|| (scopeType !== "tenant" && !scopeId)
}
>
<CopyPlus size={16} /> Create copy
</Button>
@@ -1192,7 +1236,10 @@ function DerivePipelineDialog({
<FormField label="Target scope">
<select
value={scopeType}
onChange={(event) => setScopeType(event.target.value as typeof scopeType)}
onChange={(event) => {
setScopeType(event.target.value as typeof scopeType);
setScopeId("");
}}
>
<option value="tenant">Tenant</option>
<option value="group">Group</option>
@@ -1200,11 +1247,19 @@ function DerivePipelineDialog({
</select>
</FormField>
{scopeType !== "tenant" ? (
<FormField label="Scope ID">
<input
<FormField label={scopeType === "user" ? "User" : "Group"}>
<ReferenceSelect
value={scopeId}
placeholder={scopeType === "user" ? "Current user when empty" : "Group ID"}
onChange={(event) => setScopeId(event.target.value)}
onChange={(value) => setScopeId(value)}
provider={scopeProvider}
aria-label={
scopeType === "user" ? "Copy target user" : "Copy target group"
}
placeholder={
scopeType === "user" ? "Select a user" : "Select a group"
}
disabled={busy}
required
/>
</FormField>
) : null}
@@ -0,0 +1,103 @@
import { useCallback } from "react";
import { Waypoints } from "lucide-react";
import { Link } from "react-router-dom";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
StatusBadge,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listDataflowPipelines,
type Pipeline
} from "../../api/dataflow";
export default function DataflowPipelinesWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const includeDrafts = configuration.includeDrafts !== false;
const load = useCallback(async () => {
const pipelines = await listDataflowPipelines(settings);
return pipelines
.filter((pipeline) => includeDrafts || pipeline.status !== "draft")
.sort(comparePipelines)
.slice(0, maxItems);
}, [includeDrafts, maxItems, settings]);
const { data: pipelines, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label="Loading dataflows">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No dataflows are available."
items={(pipelines ?? []).map((pipeline) => ({
id: pipeline.id,
title: pipeline.name,
detail: pipelineDescription(pipeline),
meta: scopeLabel(pipeline),
leading: <Waypoints size={17} aria-hidden="true" />,
trailing: (
<StatusBadge status={pipeline.status} label={pipeline.status} />
),
to: "/dataflow"
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/dataflow">
Open dataflow
</Link>
</div>
</LoadingFrame>
);
}
function comparePipelines(left: Pipeline, right: Pipeline): number {
if (left.status !== right.status) {
if (left.status === "active") return -1;
if (right.status === "active") return 1;
}
return (
new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime()
);
}
function pipelineDescription(pipeline: Pipeline): string {
const nodeCount = pipeline.revision.graph.nodes.length;
const kind =
pipeline.governance.definition_kind === "template" ? "template" : "flow";
return `${nodeCount} ${nodeCount === 1 ? "node" : "nodes"} · ${kind}`;
}
function scopeLabel(pipeline: Pipeline): string {
const scope = pipeline.governance.scope_type;
return scope.charAt(0).toUpperCase() + scope.slice(1);
}
function numberSetting(
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.floor(numeric)))
: fallback;
}
+62 -2
View File
@@ -1,11 +1,59 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import type {
DashboardWidgetsUiCapability,
PlatformWebModule
} from "@govoplan/core-webui";
import DataflowPipelinesWidget from "./features/dataflow/DataflowPipelinesWidget";
import "@xyflow/react/dist/style.css";
import "./styles/dataflow.css";
const DataflowPage = lazy(() => import("./features/dataflow/DataflowPage"));
const readScopes = ["dataflow:pipeline:read", "dataflow:pipeline:admin"];
const dataflowDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "dataflow.pipelines",
surfaceId: "dataflow.widget.pipelines",
title: "Dataflows",
description: "Active and recently edited dataflow definitions.",
moduleId: "dataflow",
category: "Automation",
order: 70,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: readScopes,
refreshIntervalMs: 60_000,
defaultConfiguration: {
maxItems: 5,
includeDrafts: true
},
configurationFields: [
{
id: "maxItems",
label: "Maximum dataflows",
kind: "number",
min: 1,
max: 12,
step: 1,
required: true
},
{
id: "includeDrafts",
label: "Include drafts",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(DataflowPipelinesWidget, {
settings,
refreshKey,
configuration
})
}
]
};
export const dataflowModule: PlatformWebModule = {
id: "dataflow",
@@ -22,6 +70,15 @@ export const dataflowModule: PlatformWebModule = {
"risk_compliance",
"workflow"
],
viewSurfaces: [
{
id: "dataflow.widget.pipelines",
moduleId: "dataflow",
kind: "section",
label: "Dataflows widget",
order: 70
}
],
navItems: [
{ to: "/dataflow", label: "Dataflow", iconName: "waypoints", anyOf: readScopes, order: 72 }
],
@@ -32,7 +89,10 @@ export const dataflowModule: PlatformWebModule = {
order: 72,
render: ({ settings, auth }) => createElement(DataflowPage, { settings, auth })
}
]
],
uiCapabilities: {
"dashboard.widgets": dataflowDashboardWidgets
}
};
export default dataflowModule;