feat: consume governed datasources and shared graphs

This commit is contained in:
2026-07-28 12:44:46 +02:00
parent dee8380631
commit 6ca3058021
15 changed files with 248 additions and 272 deletions

View File

@@ -1,4 +1,5 @@
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 EditorMode = "graph" | "sql";
@@ -125,17 +126,11 @@ export type NodeConfigFieldDefinition = {
options: Array<[string, string]>;
};
export type NodeTypeDefinition = {
type: string;
export type NodeTypeDefinition = DefinitionGraphNodeType & {
category: NodeCategory;
category_label: string;
label: string;
description: string;
icon: string;
input_ports: NodePortDefinition[];
output_ports: NodePortDefinition[];
config_fields: NodeConfigFieldDefinition[];
default_config: Record<string, unknown>;
sql_support: "full" | "partial" | "none";
};

View File

@@ -13,6 +13,7 @@ import {
type Edge,
type ReactFlowInstance
} from "@xyflow/react";
import { definitionConnectionError } from "@govoplan/core-webui/definition-graph";
import type {
DataflowDiagnostic,
NodeTypeDefinition,
@@ -124,34 +125,17 @@ export default function DataflowCanvas({
};
const isValidConnection = (connection: Connection | Edge): boolean => {
if (readOnly || !connection.source || !connection.target || connection.source === connection.target) return false;
const source = graph.nodes.find((node) => node.id === connection.source);
const target = graph.nodes.find((node) => node.id === connection.target);
if (!source || !target || source.type === "output" || target.type.startsWith("source.")) return false;
const sourcePort = connection.sourceHandle ?? "output";
const targetPort = connection.targetHandle ?? "input";
const sourceDefinition = definitions.get(source.type);
const targetDefinition = definitions.get(target.type);
if (
!sourceDefinition?.output_ports.some((port) => port.id === sourcePort)
|| !targetDefinition?.input_ports.some((port) => port.id === targetPort)
) {
return false;
}
const duplicate = graph.edges.some(
(edge) =>
edge.source === connection.source
&& edge.target === connection.target
&& (edge.source_port ?? "output") === sourcePort
&& (edge.target_port ?? "input") === targetPort
);
if (duplicate) return false;
const port = targetDefinition.input_ports.find((item) => item.id === targetPort);
const targetPortConnections = graph.edges.filter(
(edge) => edge.target === connection.target && (edge.target_port ?? "input") === targetPort
);
if (!port?.multiple && targetPortConnections.length) return false;
return !wouldCreateCycle(graph, connection.source, connection.target);
if (readOnly || !connection.source || !connection.target) return false;
return definitionConnectionError(
graph,
nodeLibrary,
{
source: connection.source,
target: connection.target,
sourcePort: connection.sourceHandle,
targetPort: connection.targetHandle
}
) === null;
};
const onConnect = (connection: Connection) => {
@@ -234,23 +218,6 @@ export default function DataflowCanvas({
);
}
function wouldCreateCycle(graph: PipelineGraph, source: string, target: string): boolean {
const outgoing = new Map<string, string[]>();
graph.edges.forEach((edge) => {
outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge.target]);
});
const pending = [target];
const visited = new Set<string>();
while (pending.length) {
const nodeId = pending.pop();
if (!nodeId || visited.has(nodeId)) continue;
if (nodeId === source) return true;
visited.add(nodeId);
pending.push(...(outgoing.get(nodeId) ?? []));
}
return false;
}
export function updateGraphNode(
graph: PipelineGraph,
updatedNode: PipelineGraphNode

View File

@@ -106,8 +106,8 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
const canImportSources = canWrite
&& sourceCatalogueWritable
&& (
hasScope(auth, "connectors:source:write")
|| hasScope(auth, "connectors:source:admin")
hasScope(auth, "datasources:stage:write")
|| hasScope(auth, "datasources:source:admin")
);
const dirty = Boolean(draft) && draftFingerprint(draft) !== draftFingerprint(savedDraft);
const selectedNode = useMemo(
@@ -571,7 +571,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
<strong>Nodes</strong>
{canImportSources ? (
<IconButton
label="Import tabular snapshot"
label="Stage datasource"
icon={<Upload size={15} />}
variant="ghost"
onClick={() => setSnapshotOpen(true)}
@@ -815,7 +815,7 @@ function SourceSnapshotDialog({
return (
<Dialog
open={open}
title="Import tabular snapshot"
title="Stage datasource"
className="dataflow-source-dialog"
onClose={() => {
if (!busy) onClose();

View File

@@ -151,7 +151,7 @@ export default function NodeInspector({
disabled={readOnly || !sourceCatalogueAvailable}
>
<option value="">
{sourceCatalogueAvailable ? "Choose a source" : "Connectors unavailable"}
{sourceCatalogueAvailable ? "Choose a datasource" : "Datasources unavailable"}
</option>
{sources.map((source) => (
<option key={source.ref} value={source.ref}>
@@ -160,6 +160,17 @@ export default function NodeInspector({
))}
</select>
</FormField>
<FormField label="State">
<select
value={textValue(node.config.consistency) || "current"}
onChange={(event) => updateConfig({ consistency: event.target.value })}
disabled={readOnly}
>
<option value="current">Current</option>
<option value="live">Live</option>
<option value="frozen">Latest frozen</option>
</select>
</FormField>
{textValue(node.config.expected_fingerprint) ? (
<FormField label="Pinned fingerprint">
<input

View File

@@ -7,6 +7,7 @@ import type {
PipelineStatus,
NodeTypeDefinition
} from "../../api/dataflow";
import { createDefinitionGraphNode } from "@govoplan/core-webui/definition-graph";
export type PipelineDraft = {
id: string | null;
@@ -31,12 +32,17 @@ export const FALLBACK_NODE_LIBRARY: NodeTypeDefinition[] = [
"source.reference",
"load",
"Load",
"Connector source",
"Load a governed tabular source.",
"Datasource",
"Load a governed datasource state.",
"database",
[],
output,
{ source_ref: "", source_name: "connector_source", expected_fingerprint: "" }
{
source_ref: "",
source_name: "datasource",
expected_fingerprint: "",
consistency: "current"
}
),
nodeType(
"combine.union",
@@ -257,16 +263,7 @@ export function newNode(
position: { x: number; y: number },
library: NodeTypeDefinition[] = FALLBACK_NODE_LIBRARY
): PipelineGraphNode {
const id = `${type.replace(".", "-")}-${crypto.randomUUID()}`;
const definition = library.find((item) => item.type === type);
const config = structuredClone(definition?.default_config ?? {});
return {
id,
type,
label: definition?.label ?? type,
position,
config
};
return createDefinitionGraphNode<PipelineGraphNode>(type, position, library);
}
function nodeType(

View File

@@ -14,7 +14,7 @@ export const dataflowModule: PlatformWebModule = {
optionalDependencies: [
"access",
"audit",
"connectors",
"datasources",
"files",
"notifications",
"policy",

View File

@@ -21,6 +21,7 @@
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"@govoplan/core-webui/definition-graph": ["../../govoplan-core/webui/src/definitionGraph.ts"],
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
"@xyflow/react": ["../../govoplan-core/webui/node_modules/@xyflow/react/dist/esm/index.d.ts"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],