Add governed reusable pipelines and triggers
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user