feat: consume governed datasources and shared graphs
This commit is contained in:
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -14,7 +14,7 @@ export const dataflowModule: PlatformWebModule = {
|
||||
optionalDependencies: [
|
||||
"access",
|
||||
"audit",
|
||||
"connectors",
|
||||
"datasources",
|
||||
"files",
|
||||
"notifications",
|
||||
"policy",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user