Add governed reusable pipelines and triggers

This commit is contained in:
2026-07-28 15:04:23 +02:00
parent 6305ef9cef
commit 08ddc8d3e8
17 changed files with 4697 additions and 46 deletions

View File

@@ -2,6 +2,8 @@ import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
import type { DefinitionGraphNodeType } from "@govoplan/core-webui/definition-graph";
export type PipelineStatus = "draft" | "active" | "archived";
export type DefinitionScopeType = "system" | "tenant" | "group" | "user";
export type DefinitionKind = "flow" | "template";
export type EditorMode = "graph" | "sql";
export type NodeCategory = "load" | "combine" | "filter" | "transform" | "output";
@@ -48,7 +50,7 @@ export type PipelineRevision = {
export type Pipeline = {
id: string;
tenant_id: string;
tenant_id: string | null;
name: string;
description?: string | null;
status: PipelineStatus;
@@ -58,6 +60,30 @@ export type Pipeline = {
created_at: string;
updated_at: string;
revision: PipelineRevision;
governance: PipelineGovernance;
};
export type DefinitionActionDecision = {
allowed: boolean;
reason?: string | null;
source_path: Array<Record<string, unknown>>;
requirements: string[];
details: Record<string, unknown>;
};
export type PipelineGovernance = {
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_run: boolean;
allow_reuse: boolean;
allow_automation: boolean;
derived_from_pipeline_id?: string | null;
derived_from_revision?: number | null;
derived_from_hash?: string | null;
derivation_provenance: Record<string, unknown>;
actions: Record<string, DefinitionActionDecision>;
};
export type PipelinePayload = {
@@ -67,6 +93,13 @@ export type PipelinePayload = {
graph: PipelineGraph;
sql_text?: string | null;
editor_mode: EditorMode;
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_run: boolean;
allow_reuse: boolean;
allow_automation: boolean;
};
export type PipelineValidation = {
@@ -179,6 +212,11 @@ export type PipelineRun = {
output_publication_ref?: string | null;
output_datasource_ref?: string | null;
output_materialization_ref?: string | null;
invocation_kind: string;
trigger_ref?: string | null;
delivery_ref?: string | null;
correlation_id?: string | null;
causation_id?: string | null;
error?: string | null;
started_at: string;
finished_at?: string | null;
@@ -187,6 +225,52 @@ export type PipelineRun = {
replayed: boolean;
};
export type DataflowTriggerKind = "once" | "interval" | "event";
export type DataflowTrigger = {
id: string;
tenant_id: string;
pipeline_id: string;
pipeline_revision: number;
name: string;
kind: DataflowTriggerKind;
status: "enabled" | "disabled" | "completed" | "blocked";
revision: number;
schedule?: {
run_at?: string | null;
anchor_at?: string | null;
interval_seconds?: number | null;
timezone: string;
} | null;
event?: {
event_type: string;
module_id?: string | null;
filters: Record<string, string | number | boolean | null>;
} | null;
row_limit: number;
catch_up_policy: "skip" | "coalesce";
max_concurrent_runs: number;
next_fire_at?: string | null;
last_fire_at?: string | null;
last_status?: string | null;
last_error?: string | null;
grant_scopes: string[];
authorization_provenance: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type DataflowTriggerPayload = {
name: string;
kind: DataflowTriggerKind;
revision?: number | null;
enabled: boolean;
schedule?: DataflowTrigger["schedule"];
event?: DataflowTrigger["event"];
row_limit: number;
catch_up_policy: "skip" | "coalesce";
max_concurrent_runs: number;
};
export async function listDataflowNodeTypes(settings: ApiSettings): Promise<NodeTypeDefinition[]> {
const response = await apiFetch<{ nodes: NodeTypeDefinition[] }>(
settings,
@@ -260,6 +344,75 @@ export function deleteDataflowPipeline(settings: ApiSettings, pipelineId: string
);
}
export function deriveDataflowPipeline(
settings: ApiSettings,
pipelineId: string,
payload: {
name: string;
description?: string | null;
source_revision?: number | null;
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_run: boolean;
allow_reuse: boolean;
allow_automation: boolean;
}
): Promise<Pipeline> {
return apiFetch(
settings,
`/api/v1/dataflow/pipelines/${encodeURIComponent(pipelineId)}/derive`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function listDataflowTriggers(
settings: ApiSettings,
pipelineId: string
): Promise<DataflowTrigger[]> {
const response = await apiFetch<{ triggers: DataflowTrigger[] }>(
settings,
`/api/v1/dataflow/pipelines/${encodeURIComponent(pipelineId)}/triggers`
);
return response.triggers;
}
export function createDataflowTrigger(
settings: ApiSettings,
pipelineId: string,
payload: DataflowTriggerPayload
): Promise<DataflowTrigger> {
return apiFetch(
settings,
`/api/v1/dataflow/pipelines/${encodeURIComponent(pipelineId)}/triggers`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function updateDataflowTrigger(
settings: ApiSettings,
triggerId: string,
payload: DataflowTriggerPayload & { expected_revision: number }
): Promise<DataflowTrigger> {
return apiFetch(
settings,
`/api/v1/dataflow/triggers/${encodeURIComponent(triggerId)}`,
{ method: "PUT", body: JSON.stringify(payload) }
);
}
export function deleteDataflowTrigger(
settings: ApiSettings,
triggerId: string
): Promise<{ deleted: boolean }> {
return apiFetch(
settings,
`/api/v1/dataflow/triggers/${encodeURIComponent(triggerId)}`,
{ method: "DELETE" }
);
}
export function validateDataflowPipeline(
settings: ApiSettings,
payload: { graph?: PipelineGraph; sql_text?: string; source_nodes?: PipelineGraphNode[] }

View File

@@ -7,7 +7,9 @@ import {
} from "react";
import {
CheckCircle2,
Clock3,
Code2,
CopyPlus,
DatabaseZap,
Network,
Play,
@@ -15,6 +17,7 @@ import {
RefreshCw,
RotateCcw,
Save,
Settings2,
Trash2,
TriangleAlert,
Upload
@@ -40,19 +43,27 @@ import {
import { ReactFlowProvider } from "@xyflow/react";
import {
compileDataflowSql,
createDataflowTrigger,
createDataflowPipeline,
createDataflowSourceSnapshot,
deleteDataflowPipeline,
deleteDataflowTrigger,
deriveDataflowPipeline,
listDataflowNodeTypes,
listDataflowPipelineRuns,
listDataflowPipelines,
listDataflowSources,
listDataflowTriggers,
previewDataflowPipeline,
runDataflowPipeline,
renderDataflowSql,
updateDataflowPipeline,
updateDataflowTrigger,
validateDataflowPipeline,
type DataflowDiagnostic,
type DataflowTrigger,
type DataflowTriggerKind,
type DataflowTriggerPayload,
type EditorMode,
type NodeCategory,
type NodeTypeDefinition,
@@ -102,20 +113,61 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
const [deleteOpen, setDeleteOpen] = useState(false);
const [snapshotOpen, setSnapshotOpen] = useState(false);
const [runOpen, setRunOpen] = useState(false);
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
const [deriveOpen, setDeriveOpen] = useState(false);
const [triggersOpen, setTriggersOpen] = useState(false);
const [nodeLibrary, setNodeLibrary] = useState<NodeTypeDefinition[]>(FALLBACK_NODE_LIBRARY);
const [sources, setSources] = useState<TabularSource[]>([]);
const [sourceCatalogueAvailable, setSourceCatalogueAvailable] = useState(false);
const [sourceCatalogueWritable, setSourceCatalogueWritable] = useState(false);
const dirty = Boolean(draft) && draftFingerprint(draft) !== draftFingerprint(savedDraft);
const canWrite = hasScope(auth, "dataflow:pipeline:write") || hasScope(auth, "dataflow:pipeline:admin");
const canRun = hasScope(auth, "dataflow:pipeline:run") || hasScope(auth, "dataflow:pipeline:admin");
const canEdit = canWrite && (
!draft?.id || draft.governance?.actions.edit?.allowed !== false
);
const canReuse = Boolean(
draft?.id
&& canWrite
&& draft.governance?.actions.derive?.allowed
);
const canStartSavedRun = Boolean(
draft?.id
&& draft.definitionKind === "flow"
&& draft.governance?.actions.run?.allowed
);
const canPreview = Boolean(
canRun
&& draft
&& (
!draft.id
|| dirty
|| draft.status === "draft"
? canEdit
: draft.governance?.actions.run?.allowed
)
);
const canManageTriggers = Boolean(
draft?.id
&& (
hasScope(auth, "dataflow:trigger:write")
|| hasScope(auth, "dataflow:pipeline:admin")
)
);
const canViewTriggers = Boolean(
draft?.id
&& (
hasScope(auth, "dataflow:trigger:read")
|| canManageTriggers
)
);
const canImportSources = canWrite
&& sourceCatalogueWritable
&& (
hasScope(auth, "datasources:stage:write")
|| hasScope(auth, "datasources:source:admin")
);
const dirty = Boolean(draft) && draftFingerprint(draft) !== draftFingerprint(savedDraft);
const selectedNode = useMemo(
() => draft?.graph.nodes.find((node) => node.id === selectedNodeId) ?? null,
[draft, selectedNodeId]
@@ -208,7 +260,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
}, [savedDraft]);
const saveDraft = useCallback(async (): Promise<boolean> => {
if (!draft || !canWrite || !draft.name.trim()) {
if (!draft || !canEdit || !draft.name.trim()) {
setError(!draft?.name.trim() ? "Pipeline name is required." : "You cannot save this pipeline.");
return false;
}
@@ -239,7 +291,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
} finally {
setSaving(false);
}
}, [canWrite, draft, settings]);
}, [canEdit, draft, settings]);
useUnsavedDraftGuard({
dirty,
@@ -425,7 +477,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
};
const addNode = (type: string) => {
if (!draft || !canWrite || uniqueNodeExists(draft, type)) return;
if (!draft || !canEdit || uniqueNodeExists(draft, type)) return;
const node = newNode(type, {
x: 120 + draft.graph.nodes.length * 70,
y: 100 + (draft.graph.nodes.length % 4) * 80
@@ -478,6 +530,9 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
<span>
<strong>{pipeline.name}</strong>
<small>Revision {pipeline.current_revision}</small>
<small>
{pipeline.governance.scope_type} · {pipeline.governance.definition_kind}
</small>
</span>
<StatusBadge status={pipeline.status} />
</button>
@@ -501,7 +556,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
value={draft.name}
onChange={(event) => updateDraft({ name: event.target.value })}
aria-label="Pipeline name"
disabled={!canWrite}
disabled={!canEdit}
/>
<input
className="dataflow-description-input"
@@ -509,16 +564,18 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
onChange={(event) => updateDraft({ description: event.target.value })}
placeholder="Description"
aria-label="Pipeline description"
disabled={!canWrite}
disabled={!canEdit}
/>
<select
value={draft.status}
onChange={(event) => updateDraft({ status: event.target.value as PipelineDraft["status"] })}
aria-label="Pipeline status"
disabled={!canWrite}
disabled={!canEdit}
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="active" disabled={draft.definitionKind === "template"}>
Active
</option>
<option value="archived">Archived</option>
</select>
</div>
@@ -536,16 +593,52 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
<Button onClick={() => void validate()} disabled={working}>
<CheckCircle2 size={16} /> Validate
</Button>
<Button variant="primary" onClick={() => void runPreview()} disabled={working || !canRun}>
<Button
variant="primary"
onClick={() => void runPreview()}
disabled={working || !canPreview}
disabledReason={
draft.governance?.actions.run?.reason ?? undefined
}
>
<Play size={16} /> Preview
</Button>
<Button
onClick={() => setRunOpen(true)}
disabled={working || !canRun || dirty || !draft.id || !draft.currentRevision}
title={dirty ? "Save the pipeline before starting a pinned run." : undefined}
disabled={working || !canRun || dirty || !canStartSavedRun || !draft.currentRevision}
disabledReason={
dirty
? "Save the pipeline before starting a pinned run."
: draft.governance?.actions.run?.reason ?? undefined
}
>
<DatabaseZap size={16} /> Run
</Button>
<IconButton
label="Definition settings"
icon={<Settings2 size={16} />}
variant="ghost"
onClick={() => setDefinitionSettingsOpen(true)}
disabled={!draft}
/>
{draft.id ? (
<IconButton
label="Reuse as scoped copy"
icon={<CopyPlus size={16} />}
variant="ghost"
onClick={() => setDeriveOpen(true)}
disabled={!canReuse}
/>
) : null}
{draft.id ? (
<IconButton
label="Automation triggers"
icon={<Clock3 size={16} />}
variant="ghost"
onClick={() => setTriggersOpen(true)}
disabled={!canViewTriggers}
/>
) : null}
<IconButton
label="Discard changes"
icon={<RotateCcw size={16} />}
@@ -559,13 +652,13 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
icon={<Trash2 size={16} />}
variant="danger"
onClick={() => setDeleteOpen(true)}
disabled={!canWrite || saving}
disabled={!canEdit || saving}
/>
) : null}
<Button
variant="primary"
onClick={() => void saveDraft()}
disabled={saving || working || !dirty || !canWrite}
disabled={saving || working || !dirty || !canEdit}
>
<Save size={16} /> {saving ? "Saving..." : "Save"}
</Button>
@@ -597,7 +690,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
<h3>{group.label}</h3>
{group.nodes.map((definition) => {
const Icon = dataflowNodeIcon(definition.icon);
const disabled = !canWrite || uniqueNodeExists(draft, definition.type);
const disabled = !canEdit || uniqueNodeExists(draft, definition.type);
return (
<button
key={definition.type}
@@ -628,7 +721,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
nodeDiagnostics={nodeDiagnostics}
nodeLibrary={nodeLibrary}
selectedNodeId={selectedNodeId}
readOnly={!canWrite}
readOnly={!canEdit}
onGraphChange={updateGraph}
onSelectNode={setSelectedNodeId}
/>
@@ -640,7 +733,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
<Button
variant="primary"
onClick={() => void applySql(false)}
disabled={working || !canWrite}
disabled={working || !canEdit}
>
<Code2 size={16} /> Apply SQL
</Button>
@@ -649,7 +742,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
value={draft.sqlText}
onChange={(event) => updateDraft({ sqlText: event.target.value })}
spellCheck={false}
disabled={!canWrite}
disabled={!canEdit}
aria-label="Dataflow SQL"
/>
</div>
@@ -660,7 +753,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
nodeLibrary={nodeLibrary}
sources={sources}
sourceCatalogueAvailable={sourceCatalogueAvailable}
readOnly={!canWrite}
readOnly={!canEdit}
onChange={(node: PipelineGraphNode) => updateGraph(updateGraphNode(draft.graph, node))}
onDelete={(nodeId) => {
updateGraph({
@@ -766,10 +859,587 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
}
}}
/>
<DefinitionSettingsDialog
open={definitionSettingsOpen}
draft={draft}
editable={canEdit}
onChange={updateDraft}
onClose={() => setDefinitionSettingsOpen(false)}
/>
<DerivePipelineDialog
open={deriveOpen}
settings={settings}
pipeline={draft?.id ? {
id: draft.id,
name: draft.name,
revision: draft.currentRevision ?? 1
} : null}
onClose={() => setDeriveOpen(false)}
onDerived={(pipeline) => {
const next = draftFromPipeline(pipeline);
setPipelines((current) => [
pipeline,
...current.filter((item) => item.id !== pipeline.id)
]);
setDraft(next);
setSavedDraft(structuredClone(next));
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setDeriveOpen(false);
setSuccess("Created a pinned scoped copy.");
}}
/>
<DataflowTriggersDialog
open={triggersOpen}
settings={settings}
pipeline={draft?.id ? {
id: draft.id,
name: draft.name,
revision: draft.currentRevision ?? 1
} : null}
editable={canManageTriggers}
onClose={() => setTriggersOpen(false)}
/>
</main>
);
}
function DefinitionSettingsDialog({
open,
draft,
editable,
onChange,
onClose
}: {
open: boolean;
draft: PipelineDraft | null;
editable: boolean;
onChange: (patch: Partial<PipelineDraft>) => void;
onClose: () => void;
}) {
const provenance = draft?.governance?.actions.edit?.source_path ?? [];
return (
<Dialog
open={open}
title="Definition settings"
className="dataflow-definition-dialog"
onClose={onClose}
footer={<Button onClick={onClose}>Close</Button>}
>
{draft ? (
<div className="dataflow-definition-fields">
<FormField
label="Scope"
help="The scope determines ownership, visibility, and where Policy inheritance is resolved."
>
<select
value={draft.scopeType}
disabled={!editable || Boolean(draft.id)}
onChange={(event) => onChange({
scopeType: event.target.value as PipelineDraft["scopeType"],
scopeId: ""
})}
>
<option value="system">System</option>
<option value="tenant">Tenant</option>
<option value="group">Group</option>
<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"
? "Current user when empty"
: "Required for group"
}
onChange={(event) => onChange({ scopeId: event.target.value })}
/>
</FormField>
<FormField
label="Definition kind"
help="Templates can be reused or derived, but cannot be run or automated directly."
>
<select
value={draft.definitionKind}
disabled={!editable || Boolean(draft.id)}
onChange={(event) => onChange({
definitionKind: event.target.value as PipelineDraft["definitionKind"],
status: "draft"
})}
>
<option value="flow">Complete flow</option>
<option value="template">Template</option>
</select>
</FormField>
<div className="dataflow-definition-toggles">
<ToggleSwitch
label="Visible to lower scopes"
checked={draft.inheritToLowerScopes}
disabled={!editable}
onChange={(value) => onChange({ inheritToLowerScopes: value })}
/>
<ToggleSwitch
label="Allow runs"
checked={draft.allowRun}
disabled={!editable}
onChange={(value) => onChange({ allowRun: value })}
/>
<ToggleSwitch
label="Allow reuse"
checked={draft.allowReuse}
disabled={!editable}
onChange={(value) => onChange({ allowReuse: value })}
/>
<ToggleSwitch
label="Allow automation"
checked={draft.allowAutomation}
disabled={!editable}
onChange={(value) => onChange({ allowAutomation: value })}
/>
</div>
{draft.governance?.derived_from_pipeline_id ? (
<section className="dataflow-provenance">
<strong>Derived from</strong>
<span>
{draft.governance.derived_from_pipeline_id}
{" · revision "}
{draft.governance.derived_from_revision}
</span>
<code>{draft.governance.derived_from_hash}</code>
</section>
) : null}
{provenance.length ? (
<section className="dataflow-provenance">
<strong>Effective Policy path</strong>
{provenance.map((item, index) => (
<span key={`${String(item.path ?? item.scope_type)}-${index}`}>
{String(item.label ?? item.path ?? item.scope_type)}
</span>
))}
{!editable && draft.governance?.actions.edit?.reason ? (
<small>{draft.governance.actions.edit.reason}</small>
) : null}
</section>
) : null}
</div>
) : null}
</Dialog>
);
}
function DerivePipelineDialog({
open,
settings,
pipeline,
onClose,
onDerived
}: {
open: boolean;
settings: ApiSettings;
pipeline: { id: string; name: string; revision: number } | null;
onClose: () => void;
onDerived: (pipeline: Pipeline) => void;
}) {
const [name, setName] = useState("");
const [kind, setKind] = useState<"flow" | "template">("flow");
const [scopeType, setScopeType] = useState<"tenant" | "group" | "user">("tenant");
const [scopeId, setScopeId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setName(`${pipeline?.name ?? "Pipeline"} copy`);
setKind("flow");
setScopeType("tenant");
setScopeId("");
setError("");
}, [open, pipeline?.id]);
const derive = async () => {
if (!pipeline || !name.trim()) return;
setBusy(true);
setError("");
try {
onDerived(await deriveDataflowPipeline(settings, pipeline.id, {
name: name.trim(),
source_revision: pipeline.revision,
scope_type: scopeType,
scope_id: scopeId.trim() || null,
definition_kind: kind,
inherit_to_lower_scopes: false,
allow_run: true,
allow_reuse: false,
allow_automation: false
}));
} catch (deriveError) {
setError(apiErrorMessage(deriveError));
} finally {
setBusy(false);
}
};
return (
<Dialog
open={open}
title="Reuse as scoped copy"
className="dataflow-definition-dialog"
closeDisabled={busy}
onClose={onClose}
footer={(
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button
variant="primary"
onClick={() => void derive()}
disabled={busy || !pipeline || !name.trim()}
>
<CopyPlus size={16} /> Create copy
</Button>
</>
)}
>
<div className="dataflow-definition-fields">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<FormField label="Name">
<input value={name} onChange={(event) => setName(event.target.value)} />
</FormField>
<FormField label="Target scope">
<select
value={scopeType}
onChange={(event) => setScopeType(event.target.value as typeof scopeType)}
>
<option value="tenant">Tenant</option>
<option value="group">Group</option>
<option value="user">User</option>
</select>
</FormField>
{scopeType !== "tenant" ? (
<FormField label="Scope ID">
<input
value={scopeId}
placeholder={scopeType === "user" ? "Current user when empty" : "Group ID"}
onChange={(event) => setScopeId(event.target.value)}
/>
</FormField>
) : null}
<FormField label="Copy kind">
<select value={kind} onChange={(event) => setKind(event.target.value as typeof kind)}>
<option value="flow">Complete flow</option>
<option value="template">Template</option>
</select>
</FormField>
<small>
The source revision and content hash are pinned. Source Policy limits
are retained and can only be narrowed in the copy.
</small>
</div>
</Dialog>
);
}
function DataflowTriggersDialog({
open,
settings,
pipeline,
editable,
onClose
}: {
open: boolean;
settings: ApiSettings;
pipeline: { id: string; name: string; revision: number } | null;
editable: boolean;
onClose: () => void;
}) {
const [triggers, setTriggers] = useState<DataflowTrigger[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [name, setName] = useState("");
const [kind, setKind] = useState<DataflowTriggerKind>("interval");
const [enabled, setEnabled] = useState(false);
const [runAt, setRunAt] = useState("");
const [intervalMinutes, setIntervalMinutes] = useState(60);
const [catchUpPolicy, setCatchUpPolicy] = useState<DataflowTrigger["catch_up_policy"]>("coalesce");
const [maxConcurrentRuns, setMaxConcurrentRuns] = useState(1);
const [eventType, setEventType] = useState("");
const [eventModule, setEventModule] = useState("");
const [eventFilters, setEventFilters] = useState("{}");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const selected = triggers.find((item) => item.id === selectedId) ?? null;
const load = useCallback(async () => {
if (!pipeline) return;
setBusy(true);
setError("");
try {
setTriggers(await listDataflowTriggers(settings, pipeline.id));
} catch (loadError) {
setError(apiErrorMessage(loadError));
} finally {
setBusy(false);
}
}, [pipeline?.id, settings]);
useEffect(() => {
if (!open || !pipeline) return;
setSelectedId(null);
setName(`${pipeline.name} schedule`);
setKind("interval");
setEnabled(false);
setRunAt("");
setIntervalMinutes(60);
setCatchUpPolicy("coalesce");
setMaxConcurrentRuns(1);
setEventType("");
setEventModule("");
setEventFilters("{}");
void load();
}, [open, pipeline?.id, load]);
const edit = (trigger: DataflowTrigger) => {
setSelectedId(trigger.id);
setName(trigger.name);
setKind(trigger.kind);
setEnabled(trigger.status === "enabled");
setRunAt(toLocalDateTime(trigger.schedule?.run_at ?? ""));
setIntervalMinutes(Math.max(1, Math.round((trigger.schedule?.interval_seconds ?? 3600) / 60)));
setCatchUpPolicy(trigger.catch_up_policy);
setMaxConcurrentRuns(trigger.max_concurrent_runs);
setEventType(trigger.event?.event_type ?? "");
setEventModule(trigger.event?.module_id ?? "");
setEventFilters(JSON.stringify(trigger.event?.filters ?? {}, null, 2));
setError("");
};
const reset = () => {
setSelectedId(null);
setName(`${pipeline?.name ?? "Pipeline"} schedule`);
setKind("interval");
setEnabled(false);
setRunAt("");
setIntervalMinutes(60);
setCatchUpPolicy("coalesce");
setMaxConcurrentRuns(1);
setEventType("");
setEventModule("");
setEventFilters("{}");
setError("");
};
const save = async () => {
if (!pipeline || !name.trim()) return;
setBusy(true);
setError("");
try {
const filters = kind === "event"
? JSON.parse(eventFilters || "{}") as Record<string, string | number | boolean | null>
: {};
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const payload: DataflowTriggerPayload = {
name: name.trim(),
kind,
revision: pipeline.revision,
enabled,
schedule: kind === "once"
? { run_at: new Date(runAt).toISOString(), timezone }
: kind === "interval"
? { interval_seconds: intervalMinutes * 60, timezone }
: null,
event: kind === "event" ? {
event_type: eventType.trim(),
module_id: eventModule.trim() || null,
filters
} : null,
row_limit: 500,
catch_up_policy: catchUpPolicy,
max_concurrent_runs: maxConcurrentRuns
};
const saved = selected
? await updateDataflowTrigger(settings, selected.id, {
...payload,
expected_revision: selected.revision
})
: await createDataflowTrigger(settings, pipeline.id, payload);
setTriggers((current) => [
saved,
...current.filter((item) => item.id !== saved.id)
]);
edit(saved);
} catch (saveError) {
setError(
saveError instanceof SyntaxError
? "Event filters must be a JSON object with scalar values."
: apiErrorMessage(saveError)
);
} finally {
setBusy(false);
}
};
const remove = async (trigger: DataflowTrigger) => {
setBusy(true);
setError("");
try {
await deleteDataflowTrigger(settings, trigger.id);
setTriggers((current) => current.filter((item) => item.id !== trigger.id));
reset();
} catch (deleteError) {
setError(apiErrorMessage(deleteError));
} finally {
setBusy(false);
}
};
const complete = Boolean(
name.trim()
&& (kind !== "once" || runAt)
&& (kind !== "event" || eventType.trim())
);
return (
<Dialog
open={open}
title={`Automation · ${pipeline?.name ?? "pipeline"}`}
className="dataflow-triggers-dialog"
closeDisabled={busy}
onClose={onClose}
footer={(
<>
<Button onClick={onClose} disabled={busy}>Close</Button>
<Button onClick={reset} disabled={busy || !editable}>New trigger</Button>
<Button variant="primary" onClick={() => void save()} disabled={busy || !editable || !complete}>
<Save size={16} /> Save trigger
</Button>
</>
)}
>
<div className="dataflow-triggers-layout">
<div className="dataflow-trigger-list">
{triggers.map((trigger) => (
<button
key={trigger.id}
type="button"
className={trigger.id === selectedId ? "is-selected" : ""}
onClick={() => edit(trigger)}
>
<span>
<strong>{trigger.name}</strong>
<small>
{trigger.kind}
{trigger.next_fire_at ? ` · ${new Date(trigger.next_fire_at).toLocaleString()}` : ""}
</small>
</span>
<StatusBadge status={trigger.status} label={trigger.status} />
</button>
))}
{!busy && !triggers.length ? <small>No triggers configured</small> : null}
</div>
<div className="dataflow-trigger-form">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<FormField label="Name">
<input disabled={!editable} value={name} onChange={(event) => setName(event.target.value)} />
</FormField>
<FormField label="Trigger">
<select disabled={!editable} value={kind} onChange={(event) => setKind(event.target.value as DataflowTriggerKind)}>
<option value="once">Given time</option>
<option value="interval">Interval</option>
<option value="event">Platform event</option>
</select>
</FormField>
{kind === "once" ? (
<FormField label="Run at">
<input disabled={!editable} type="datetime-local" value={runAt} onChange={(event) => setRunAt(event.target.value)} />
</FormField>
) : null}
{kind === "interval" ? (
<>
<FormField label="Interval (minutes)">
<input
type="number"
min={1}
disabled={!editable}
value={intervalMinutes}
onChange={(event) => setIntervalMinutes(Math.max(1, Number(event.target.value)))}
/>
</FormField>
<FormField label="Missed runs">
<select
disabled={!editable}
value={catchUpPolicy}
onChange={(event) => setCatchUpPolicy(event.target.value as DataflowTrigger["catch_up_policy"])}
>
<option value="coalesce">Run one latest occurrence</option>
<option value="skip">Skip missed occurrences</option>
</select>
</FormField>
</>
) : null}
{kind === "event" ? (
<>
<FormField label="Event type">
<input disabled={!editable} value={eventType} onChange={(event) => setEventType(event.target.value)} />
</FormField>
<FormField label="Source module">
<input disabled={!editable} value={eventModule} onChange={(event) => setEventModule(event.target.value)} />
</FormField>
<FormField
label="Exact payload filters"
help="Only direct scalar payload fields are matched. No expressions are executed."
>
<textarea disabled={!editable} value={eventFilters} onChange={(event) => setEventFilters(event.target.value)} />
</FormField>
</>
) : null}
<ToggleSwitch
label="Enabled"
checked={enabled}
disabled={busy || !editable}
onChange={setEnabled}
/>
<FormField label="Concurrent runs">
<input
type="number"
min={1}
max={10}
disabled={!editable}
value={maxConcurrentRuns}
onChange={(event) => {
const value = Number(event.target.value);
setMaxConcurrentRuns(Math.max(1, Math.min(10, value || 1)));
}}
/>
</FormField>
{selected ? (
<section className="dataflow-trigger-security">
<strong>Execution grant</strong>
{selected.grant_scopes.map((scope) => <code key={scope}>{scope}</code>)}
<small>Authorization is resolved again for every delivery.</small>
{selected.last_error ? <small className="is-error">{selected.last_error}</small> : null}
<Button variant="danger" onClick={() => void remove(selected)} disabled={busy || !editable}>
<Trash2 size={16} /> Delete trigger
</Button>
</section>
) : null}
</div>
</div>
</Dialog>
);
}
function toLocalDateTime(value: string): string {
if (!value) return "";
const date = new Date(value);
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
}
type RunMode = "run" | "publish";
function RunPipelineDialog({

View File

@@ -5,7 +5,10 @@ import type {
PipelineGraphNode,
PipelinePayload,
PipelineStatus,
NodeTypeDefinition
NodeTypeDefinition,
DefinitionKind,
DefinitionScopeType,
PipelineGovernance
} from "../../api/dataflow";
import { createDefinitionGraphNode } from "@govoplan/core-webui/definition-graph";
@@ -18,6 +21,14 @@ export type PipelineDraft = {
graph: PipelineGraph;
sqlText: string;
editorMode: EditorMode;
scopeType: DefinitionScopeType;
scopeId: string;
definitionKind: DefinitionKind;
inheritToLowerScopes: boolean;
allowRun: boolean;
allowReuse: boolean;
allowAutomation: boolean;
governance: PipelineGovernance | null;
};
const input = [{ id: "input", label: "Input", required: true, multiple: false, minimum_connections: 1 }];
@@ -154,7 +165,15 @@ export function draftFromPipeline(pipeline: Pipeline): PipelineDraft {
status: pipeline.status,
graph: structuredClone(pipeline.revision.graph),
sqlText: pipeline.revision.sql_text ?? "",
editorMode: pipeline.revision.editor_mode
editorMode: pipeline.revision.editor_mode,
scopeType: pipeline.governance.scope_type,
scopeId: pipeline.governance.scope_id ?? "",
definitionKind: pipeline.governance.definition_kind,
inheritToLowerScopes: pipeline.governance.inherit_to_lower_scopes,
allowRun: pipeline.governance.allow_run,
allowReuse: pipeline.governance.allow_reuse,
allowAutomation: pipeline.governance.allow_automation,
governance: pipeline.governance
};
}
@@ -167,6 +186,14 @@ export function sampleDraft(): PipelineDraft {
status: "draft",
editorMode: "graph",
sqlText: "",
scopeType: "tenant",
scopeId: "",
definitionKind: "flow",
inheritToLowerScopes: false,
allowRun: true,
allowReuse: false,
allowAutomation: false,
governance: null,
graph: {
schema_version: 1,
nodes: [
@@ -238,7 +265,14 @@ export function pipelinePayload(draft: PipelineDraft): PipelinePayload {
status: draft.status,
graph: draft.graph,
sql_text: draft.sqlText.trim() || null,
editor_mode: draft.editorMode
editor_mode: draft.editorMode,
scope_type: draft.scopeType,
scope_id: draft.scopeId.trim() || null,
definition_kind: draft.definitionKind,
inherit_to_lower_scopes: draft.inheritToLowerScopes,
allow_run: draft.allowRun,
allow_reuse: draft.allowReuse,
allow_automation: draft.allowAutomation
};
}
@@ -254,7 +288,14 @@ export function draftFingerprint(draft: PipelineDraft | null): string {
status: draft.status,
graph: draft.graph,
sqlText: draft.sqlText,
editorMode: draft.editorMode
editorMode: draft.editorMode,
scopeType: draft.scopeType,
scopeId: draft.scopeId,
definitionKind: draft.definitionKind,
inheritToLowerScopes: draft.inheritToLowerScopes,
allowRun: draft.allowRun,
allowReuse: draft.allowReuse,
allowAutomation: draft.allowAutomation
});
}

View File

@@ -133,6 +133,128 @@
white-space: nowrap;
}
.dataflow-definition-dialog {
width: min(620px, calc(100vw - 32px));
}
.dataflow-definition-fields,
.dataflow-trigger-form {
display: grid;
gap: 12px;
}
.dataflow-definition-fields select,
.dataflow-definition-fields input,
.dataflow-trigger-form select,
.dataflow-trigger-form input,
.dataflow-trigger-form textarea {
width: 100%;
}
.dataflow-definition-toggles {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
}
.dataflow-provenance,
.dataflow-trigger-security {
display: grid;
gap: 6px;
padding: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
}
.dataflow-provenance span,
.dataflow-provenance small,
.dataflow-trigger-security small {
color: var(--muted);
font-size: 12px;
}
.dataflow-provenance code,
.dataflow-trigger-security code {
overflow: hidden;
color: var(--text);
font-size: 11px;
text-overflow: ellipsis;
}
.dataflow-triggers-dialog {
width: min(920px, calc(100vw - 32px));
}
.dataflow-triggers-layout {
display: grid;
grid-template-columns: minmax(230px, 0.8fr) minmax(320px, 1.2fr);
gap: 14px;
min-height: 420px;
}
.dataflow-trigger-list {
min-width: 0;
overflow: auto;
border-right: var(--border-line);
padding-right: 10px;
}
.dataflow-trigger-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 52px;
padding: 8px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
text-align: left;
}
.dataflow-trigger-list > button:hover,
.dataflow-trigger-list > button.is-selected {
background: var(--primary-soft);
}
.dataflow-trigger-list span,
.dataflow-trigger-list strong,
.dataflow-trigger-list small {
display: block;
min-width: 0;
}
.dataflow-trigger-list small {
margin-top: 4px;
color: var(--muted);
}
.dataflow-trigger-form textarea {
min-height: 88px;
resize: vertical;
}
.dataflow-trigger-security .is-error {
color: var(--danger);
}
@media (max-width: 760px) {
.dataflow-triggers-layout,
.dataflow-definition-toggles {
grid-template-columns: 1fr;
}
.dataflow-trigger-list {
max-height: 180px;
border-right: 0;
border-bottom: var(--border-line);
padding: 0 0 10px;
}
}
.dataflow-pipeline-list strong {
color: var(--text-strong);
font-size: 13px;