|
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
|
|
useCallback,
|
|
|
|
|
useEffect,
|
|
|
|
|
useMemo,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
type DragEvent
|
|
|
|
|
} from "react";
|
|
|
|
@@ -92,6 +93,7 @@ type ResultTab = "preview" | "diagnostics";
|
|
|
|
|
type SnapshotFormat = "json" | "csv";
|
|
|
|
|
|
|
|
|
|
const NODE_CATEGORY_ORDER: NodeCategory[] = ["load", "combine", "filter", "transform", "output"];
|
|
|
|
|
const AUTO_PREVIEW_STORAGE_KEY = "govoplan.dataflow.auto-preview";
|
|
|
|
|
|
|
|
|
|
export default function DataflowPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
|
|
|
|
const { requestNavigation, requestDiscard } = useUnsavedChanges();
|
|
|
|
@@ -108,6 +110,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|
|
|
|
const [diagnostics, setDiagnostics] = useState<DataflowDiagnostic[]>([]);
|
|
|
|
|
const [nodeDiagnostics, setNodeDiagnostics] = useState<NodePreviewDiagnostic[]>([]);
|
|
|
|
|
const [preview, setPreview] = useState<PipelinePreview | null>(null);
|
|
|
|
|
const [autoPreview, setAutoPreview] = useState(readAutoPreviewPreference);
|
|
|
|
|
const [resultOpen, setResultOpen] = useState(false);
|
|
|
|
|
const [resultTab, setResultTab] = useState<ResultTab>("preview");
|
|
|
|
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
|
|
@@ -120,6 +123,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|
|
|
|
const [sources, setSources] = useState<TabularSource[]>([]);
|
|
|
|
|
const [sourceCatalogueAvailable, setSourceCatalogueAvailable] = useState(false);
|
|
|
|
|
const [sourceCatalogueWritable, setSourceCatalogueWritable] = useState(false);
|
|
|
|
|
const previewRequestId = useRef(0);
|
|
|
|
|
|
|
|
|
|
const dirty = Boolean(draft) && draftFingerprint(draft) !== draftFingerprint(savedDraft);
|
|
|
|
|
const canWrite = hasScope(auth, "dataflow:pipeline:write") || hasScope(auth, "dataflow:pipeline:admin");
|
|
|
|
@@ -345,10 +349,12 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateGraph = (graph: PipelineDraft["graph"]) => {
|
|
|
|
|
previewRequestId.current += 1;
|
|
|
|
|
updateDraft({ graph });
|
|
|
|
|
setDiagnostics([]);
|
|
|
|
|
setNodeDiagnostics([]);
|
|
|
|
|
setPreview(null);
|
|
|
|
|
setWorking(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const validate = async () => {
|
|
|
|
@@ -426,13 +432,18 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|
|
|
|
await applySql(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const runPreview = async (previewNodeId?: string) => {
|
|
|
|
|
const runPreview = useCallback(async (
|
|
|
|
|
previewNodeId?: string,
|
|
|
|
|
quiet = false
|
|
|
|
|
) => {
|
|
|
|
|
if (!draft || !canPreview) return;
|
|
|
|
|
const targetNodeId = previewNodeId
|
|
|
|
|
?? draft.graph.nodes.find((node) => node.type === "output")?.id;
|
|
|
|
|
const requestId = previewRequestId.current + 1;
|
|
|
|
|
previewRequestId.current = requestId;
|
|
|
|
|
setWorking(true);
|
|
|
|
|
setError("");
|
|
|
|
|
setSuccess("");
|
|
|
|
|
if (!quiet) setSuccess("");
|
|
|
|
|
try {
|
|
|
|
|
const response = await previewDataflowPipeline(settings, !dirty && draft.id && draft.currentRevision
|
|
|
|
|
? {
|
|
|
|
@@ -448,6 +459,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|
|
|
|
preview_node_id: targetNodeId,
|
|
|
|
|
row_limit: 100
|
|
|
|
|
});
|
|
|
|
|
if (requestId !== previewRequestId.current) return;
|
|
|
|
|
setPreview(response);
|
|
|
|
|
setDiagnostics(response.diagnostics);
|
|
|
|
|
setNodeDiagnostics(response.node_diagnostics);
|
|
|
|
@@ -457,7 +469,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|
|
|
|
? "preview"
|
|
|
|
|
: "diagnostics"
|
|
|
|
|
);
|
|
|
|
|
if (response.status === "succeeded" && response.node_preview) {
|
|
|
|
|
if (!quiet && response.status === "succeeded" && response.node_preview) {
|
|
|
|
|
const node = draft.graph.nodes.find(
|
|
|
|
|
(candidate) => candidate.id === response.node_preview?.node_id
|
|
|
|
|
);
|
|
|
|
@@ -467,10 +479,38 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (previewError) {
|
|
|
|
|
if (requestId !== previewRequestId.current) return;
|
|
|
|
|
setError(apiErrorMessage(previewError));
|
|
|
|
|
} finally {
|
|
|
|
|
setWorking(false);
|
|
|
|
|
if (requestId === previewRequestId.current) setWorking(false);
|
|
|
|
|
}
|
|
|
|
|
}, [canPreview, dirty, draft, settings]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (
|
|
|
|
|
!autoPreview
|
|
|
|
|
|| !selectedNodeId
|
|
|
|
|
|| !canPreview
|
|
|
|
|
|| draft?.editorMode !== "graph"
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const timeout = window.setTimeout(() => {
|
|
|
|
|
void runPreview(selectedNodeId, true);
|
|
|
|
|
}, 350);
|
|
|
|
|
return () => window.clearTimeout(timeout);
|
|
|
|
|
}, [autoPreview, canPreview, draft?.editorMode, runPreview, selectedNodeId]);
|
|
|
|
|
|
|
|
|
|
const selectNode = useCallback((nodeId: string | null) => {
|
|
|
|
|
setSelectedNodeId(nodeId);
|
|
|
|
|
if (nodeId && nodeId !== preview?.node_preview?.node_id) {
|
|
|
|
|
setPreview(null);
|
|
|
|
|
}
|
|
|
|
|
}, [preview?.node_preview?.node_id]);
|
|
|
|
|
|
|
|
|
|
const changeAutoPreview = (enabled: boolean) => {
|
|
|
|
|
setAutoPreview(enabled);
|
|
|
|
|
writeAutoPreviewPreference(enabled);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removePipeline = async () => {
|
|
|
|
@@ -614,14 +654,23 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
onClick={() => void runPreview()}
|
|
|
|
|
onClick={() => void runPreview(selectedNodeId ?? undefined)}
|
|
|
|
|
disabled={working || !canPreview}
|
|
|
|
|
disabledReason={
|
|
|
|
|
draft.governance?.actions.run?.reason ?? undefined
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Play size={16} /> Preview
|
|
|
|
|
{preview ? <RefreshCw size={16} /> : <Play size={16} />}
|
|
|
|
|
{preview ? "Refresh" : "Preview"}
|
|
|
|
|
</Button>
|
|
|
|
|
<ToggleSwitch
|
|
|
|
|
label="Preview updates"
|
|
|
|
|
inactiveLabel="Manual"
|
|
|
|
|
activeLabel="Auto"
|
|
|
|
|
checked={autoPreview}
|
|
|
|
|
onChange={changeAutoPreview}
|
|
|
|
|
disabled={!canPreview}
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={() => setRunOpen(true)}
|
|
|
|
|
disabled={working || !canRun || dirty || !canStartSavedRun || !draft.currentRevision}
|
|
|
|
@@ -683,11 +732,11 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{(error || success) ? (
|
|
|
|
|
<div className="dataflow-alerts">
|
|
|
|
|
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
|
|
|
|
{success ? <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert> : null}
|
|
|
|
|
</div>
|
|
|
|
|
{error ? (
|
|
|
|
|
<DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>
|
|
|
|
|
) : null}
|
|
|
|
|
{success ? (
|
|
|
|
|
<DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>
|
|
|
|
|
) : null}
|
|
|
|
|
<div className={`dataflow-editor ${draft.editorMode === "sql" ? "is-sql" : ""}`}>
|
|
|
|
|
{draft.editorMode === "graph" ? (
|
|
|
|
@@ -742,7 +791,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|
|
|
|
selectedNodeId={selectedNodeId}
|
|
|
|
|
readOnly={!canEdit}
|
|
|
|
|
onGraphChange={updateGraph}
|
|
|
|
|
onSelectNode={setSelectedNodeId}
|
|
|
|
|
onSelectNode={selectNode}
|
|
|
|
|
/>
|
|
|
|
|
</ReactFlowProvider>
|
|
|
|
|
) : (
|
|
|
|
@@ -793,10 +842,9 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|
|
|
|
onTabChange={setResultTab}
|
|
|
|
|
preview={preview}
|
|
|
|
|
nodes={draft.graph.nodes}
|
|
|
|
|
working={working}
|
|
|
|
|
diagnostics={diagnostics}
|
|
|
|
|
nodeDiagnostics={nodeDiagnostics}
|
|
|
|
|
onPreviewNodeChange={(nodeId) => void runPreview(nodeId)}
|
|
|
|
|
selectedNodeId={selectedNodeId}
|
|
|
|
|
onClose={() => setResultOpen(false)}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
@@ -1872,24 +1920,23 @@ function ResultPanel({
|
|
|
|
|
onTabChange,
|
|
|
|
|
preview,
|
|
|
|
|
nodes,
|
|
|
|
|
working,
|
|
|
|
|
diagnostics,
|
|
|
|
|
nodeDiagnostics,
|
|
|
|
|
onPreviewNodeChange,
|
|
|
|
|
selectedNodeId,
|
|
|
|
|
onClose
|
|
|
|
|
}: {
|
|
|
|
|
tab: ResultTab;
|
|
|
|
|
onTabChange: (tab: ResultTab) => void;
|
|
|
|
|
preview: PipelinePreview | null;
|
|
|
|
|
nodes: PipelineGraphNode[];
|
|
|
|
|
working: boolean;
|
|
|
|
|
diagnostics: DataflowDiagnostic[];
|
|
|
|
|
nodeDiagnostics: NodePreviewDiagnostic[];
|
|
|
|
|
onPreviewNodeChange: (nodeId: string) => void;
|
|
|
|
|
selectedNodeId: string | null;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const outputNodeId = nodes.find((node) => node.type === "output")?.id ?? "";
|
|
|
|
|
const previewNodeId = preview?.node_preview?.node_id ?? outputNodeId;
|
|
|
|
|
const previewNodeId = preview?.node_preview?.node_id ?? selectedNodeId ?? outputNodeId;
|
|
|
|
|
const previewNode = nodes.find((node) => node.id === previewNodeId);
|
|
|
|
|
const previewRows = preview?.node_preview?.total_rows ?? preview?.total_rows;
|
|
|
|
|
return (
|
|
|
|
|
<section className="dataflow-results" aria-label="Pipeline results">
|
|
|
|
@@ -1904,19 +1951,11 @@ function ResultPanel({
|
|
|
|
|
value={tab}
|
|
|
|
|
onChange={onTabChange}
|
|
|
|
|
/>
|
|
|
|
|
{tab === "preview" && nodes.length ? (
|
|
|
|
|
<select
|
|
|
|
|
aria-label="Preview stage"
|
|
|
|
|
value={previewNodeId}
|
|
|
|
|
onChange={(event) => onPreviewNodeChange(event.target.value)}
|
|
|
|
|
disabled={working}
|
|
|
|
|
>
|
|
|
|
|
{nodes.map((node) => (
|
|
|
|
|
<option key={node.id} value={node.id}>
|
|
|
|
|
{node.label}{node.type === "output" ? " (final output)" : ""}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
{tab === "preview" && previewNode ? (
|
|
|
|
|
<span className="dataflow-preview-stage">
|
|
|
|
|
{previewNode.label}
|
|
|
|
|
{previewNode.type === "output" ? " (final output)" : ""}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
<Button variant="ghost" onClick={onClose}>Close</Button>
|
|
|
|
@@ -2016,6 +2055,26 @@ function uniqueNodeExists(draft: PipelineDraft, type: string): boolean {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readAutoPreviewPreference(): boolean {
|
|
|
|
|
if (typeof window === "undefined") return true;
|
|
|
|
|
try {
|
|
|
|
|
return window.localStorage.getItem(AUTO_PREVIEW_STORAGE_KEY) !== "manual";
|
|
|
|
|
} catch {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeAutoPreviewPreference(enabled: boolean): void {
|
|
|
|
|
try {
|
|
|
|
|
window.localStorage.setItem(
|
|
|
|
|
AUTO_PREVIEW_STORAGE_KEY,
|
|
|
|
|
enabled ? "auto" : "manual"
|
|
|
|
|
);
|
|
|
|
|
} catch {
|
|
|
|
|
// The preference is non-essential when browser storage is unavailable.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startPaletteDrag(event: DragEvent<HTMLButtonElement>, type: string) {
|
|
|
|
|
event.dataTransfer.setData("application/x-govoplan-dataflow-node", type);
|
|
|
|
|
event.dataTransfer.effectAllowed = "copy";
|
|
|
|
|