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