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
+688 -18
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({
+45 -4
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
});
}