mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1442 lines
59 KiB
Svelte
1442 lines
59 KiB
Svelte
<script lang="ts">
|
||
import '@xyflow/svelte/dist/base.css'
|
||
import {
|
||
SvelteFlow,
|
||
Controls,
|
||
MiniMap,
|
||
ConnectionLineType,
|
||
type Node,
|
||
type Edge,
|
||
MarkerType
|
||
} from '@xyflow/svelte'
|
||
import AssetNode from './AssetNode.svelte'
|
||
import RunnableNode from './RunnableNode.svelte'
|
||
import TriggerNode, { type TriggerNodeKind } from './TriggerNode.svelte'
|
||
import AddNode from './AddNode.svelte'
|
||
import DataTestNode from './DataTestNode.svelte'
|
||
import AssetGraphEdge from './AssetGraphEdge.svelte'
|
||
import PanToNode from './PanToNode.svelte'
|
||
import InitialFitView from './InitialFitView.svelte'
|
||
import { layoutAssetGraph } from './assetGraphLayout'
|
||
import { computeMutedReadKeys, dbtAssociations } from './resolveGraph'
|
||
import { buildDownstreamMap } from './graphTraversal'
|
||
import { buildLineageDownstreamMap } from './boundedCascade'
|
||
import type {
|
||
AssetGraphResponse,
|
||
AssetGraphSelection,
|
||
AssetRunState,
|
||
NativeTriggerKind
|
||
} from './types'
|
||
import type { RunnableRunState } from './activeRunnables.svelte'
|
||
import type { AssetKind } from '$lib/gen'
|
||
import { NODE } from '$lib/components/graph/util'
|
||
|
||
// Width of the + node's rendered DOM element. Sugiyama allocates a full
|
||
// NODE.width slot for every node, so the small round button ends up
|
||
// left-aligned in its slot. We compensate by shifting the + node right
|
||
// by half the difference so its visual center matches the slot center.
|
||
const ADD_NODE_WIDTH = 40
|
||
|
||
interface Props {
|
||
graph: AssetGraphResponse
|
||
selection?: AssetGraphSelection | undefined
|
||
onselect?: (selection: AssetGraphSelection | undefined) => void
|
||
// Called when the user clicks the per-asset + button (consumer-script
|
||
// entry). Kept optional so the canvas stays usable outside the
|
||
// pipeline editor.
|
||
onAddScriptForAsset?: (
|
||
asset: { kind: AssetKind; path: string },
|
||
language: import('$lib/gen').ScriptLang,
|
||
scriptPath: string,
|
||
outputKind: import('./pipelineTemplates').PipelineOutputKind,
|
||
aiPrompt?: string,
|
||
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
|
||
) => void
|
||
// Pipeline-wide + node shown at the top of the graph. Picking any
|
||
// kind from the menu invokes this one callback with the chosen
|
||
// trigger source — the page uses it to seed the draft's annotation.
|
||
// `aiPrompt` is set when the user filled the optional prompt field
|
||
// on the path stage; the page is expected to bootstrap the body via
|
||
// AI instead of using the seeded template.
|
||
onAddPipelineScript?: (
|
||
language: import('$lib/gen').ScriptLang,
|
||
path: string,
|
||
source: {
|
||
kind: NativeTriggerKind
|
||
path: string | undefined
|
||
},
|
||
outputKind: import('./pipelineTemplates').PipelineOutputKind,
|
||
aiPrompt?: string,
|
||
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
|
||
) => void
|
||
// Folder-scoped prefix shown as a read-only chip in the insert menu
|
||
// path input (e.g. `f/{folder}/`). Shared across top + and per-asset +.
|
||
pathPrefix?: string
|
||
// Seeded editable suffix (e.g. `new_pipeline_script`).
|
||
defaultPathSuffix?: string
|
||
// Page-supplied dispatch for the per-asset run button. Receives the
|
||
// producer (kind/path/unsaved) and returns the new job id. The page
|
||
// implements the unsaved branch by calling runScriptPreview with the
|
||
// locally-cached draft content so users can run drafts before
|
||
// deployment. Without this callback, the per-asset run button is
|
||
// hidden.
|
||
onRunProducer?: (producer: {
|
||
kind: 'script' | 'flow'
|
||
path: string
|
||
unsaved?: boolean
|
||
// Whether to let the asset-trigger cascade fan out to downstream
|
||
// subscribers after this run succeeds. Undefined = use the caller's
|
||
// default (no skip arg injected); true = explicit cascade; false =
|
||
// inject `_wmill_skip_asset_dispatch: true` to suppress.
|
||
cascade?: boolean
|
||
}) => Promise<string | undefined>
|
||
// Page-supplied dispatch for the per-runnable-node action menu.
|
||
// Drafts are discarded immediately by the page; persisted scripts
|
||
// are routed through the details pane's archive/delete confirmation
|
||
// flow. Without this callback, the node menu button is hidden.
|
||
onRunnableMenuRemove?: (info: {
|
||
runnable_kind: 'script' | 'flow'
|
||
path: string
|
||
unsaved?: boolean
|
||
}) => void
|
||
// Runnable currently executing — its incoming asset/trigger edges and
|
||
// outgoing write edges get an animated stroke to convey "this is
|
||
// running right now". Zero-latency hint for the script the user just
|
||
// launched (set/cleared by the page from the in-pane Test state).
|
||
activeRunnable?: { kind: 'script' | 'flow'; path: string } | undefined
|
||
// `${kind}:${path}` ids of every pipeline runnable with an in-flight
|
||
// (or just-finished) job, from the folder-scoped queue poll. This is
|
||
// what lights up the *downstream cascade* (jobs the user didn't launch
|
||
// directly). Merged with `activeRunnable` so the launched script lights
|
||
// instantly while cascade hops light as the poll observes them.
|
||
activeRunnableIds?: ReadonlySet<string>
|
||
// `${kind}:${path}` → last-run status + session run count, rendered as
|
||
// a small badge on each runnable node. Same poll source as
|
||
// `activeRunnableIds`; persists the last status while idle.
|
||
runStates?: ReadonlyMap<string, RunnableRunState>
|
||
// Click handler for a "missing trigger" placeholder. The page wires
|
||
// this to its native trigger drawer set so clicking the red node
|
||
// opens the matching editor with `script_path` pre-filled — no
|
||
// navigation, drafts stay intact.
|
||
onCreateMissingTrigger?: (kind: NativeTriggerKind, scriptPath: string) => void
|
||
// Click handler for an attached (non-missing) native trigger node —
|
||
// opens the matching editor in edit mode for the given trigger path.
|
||
// `scriptPath` is the related script — the drawer locks its
|
||
// script-picker to it so the trigger can't be reassigned off the
|
||
// pipeline from this entry point.
|
||
onEditTrigger?: (kind: NativeTriggerKind, triggerPath: string, scriptPath: string) => void
|
||
// Click handler for the kebab → Delete entry on an attached trigger.
|
||
// The page is expected to confirm + call the matching delete API
|
||
// and refetch the graph.
|
||
onDeleteTrigger?: (kind: NativeTriggerKind, triggerPath: string) => void
|
||
// Click handler for a webhook node — opens the webhook drawer (URLs +
|
||
// webhook-specific token creation) for the given script. Webhooks have
|
||
// no trigger row, so they don't use the create/edit/delete flows.
|
||
onOpenWebhook?: (scriptPath: string) => void
|
||
// Click handler for a data_upload node — opens the target script's run
|
||
// form (with the auto-generated S3 picker) for the given script.
|
||
// data_upload has no trigger row; it's a UI-first entry point.
|
||
onOpenDataUpload?: (scriptPath: string) => void
|
||
// Data-upload entry scripts whose file has been staged in the run form —
|
||
// their source node renders green (ready) instead of the neutral prompt.
|
||
readyDataUploadPaths?: ReadonlySet<string>
|
||
// Script paths the cursor is over in the Activity panel — a thin neutral
|
||
// ring each. One entry for a single run row; the whole cascade's runs
|
||
// when hovering a group header. Distinct from `selectedRunPaths`.
|
||
hoveredPaths?: string[]
|
||
// Script paths of the expanded (pinned) run(s) — a soft blue ring that
|
||
// persists until collapsed.
|
||
selectedRunPaths?: string[]
|
||
// Graph-id (`${kind}:${path}`) of a node to smoothly pan into the
|
||
// center of the viewport — set by the page when a new draft node is
|
||
// created so the user's eye follows it. Opt-in: only the pipeline
|
||
// editor passes it, so the asset-graph page is unaffected. The page
|
||
// clears it once the pan has had time to settle.
|
||
panToNodeId?: string | undefined
|
||
// Script paths eligible to *start* a bounded-cascade run — roots AND
|
||
// mid-DAG models (see boundedCascade.validFromStarts). When a path is
|
||
// in this set and `onStartBoundedRun` is wired, its node's cascade menu
|
||
// gains a "Run downstream up to…" entry.
|
||
validStartPaths?: ReadonlySet<string>
|
||
onStartBoundedRun?: (startPath: string) => void
|
||
// Active bounded-run pick mode. Ids are in *canvas* space (`script:path`
|
||
// for runnables, `asset:${kind}:${path}` for assets). When set the canvas
|
||
// dims nodes outside `eligible ∪ {start}`, rings the `bounded` set, marks
|
||
// `ends`, and routes clicks on eligible nodes to `onPickEnd` instead of
|
||
// selecting.
|
||
boundPick?: {
|
||
start: string
|
||
eligible: ReadonlySet<string>
|
||
ends: ReadonlySet<string>
|
||
bounded: ReadonlySet<string>
|
||
}
|
||
onPickEnd?: (canvasNodeId: string) => void
|
||
/** Hide the minimap when the canvas is too narrow for it to be worth the
|
||
* space (e.g. stacked layout in a side panel). Defaults to shown. */
|
||
showMinimap?: boolean
|
||
/** Identity of the displayed graph (e.g. the pipeline folder). The
|
||
* initial viewport fit re-arms when it changes, so switching folders
|
||
* in-place gets a fresh fit. */
|
||
viewportFitKey?: string
|
||
/** Tint the currently-running runnable nodes amber (the compute is
|
||
* happening now). Opt-in — the recorder's replay player turns it on so
|
||
* the active transform stands out; the live editor stays calm. */
|
||
highlightActiveRun?: boolean
|
||
/** Asset node id (`asset:${kind}:${path}`) → a monotonic "recompute" nonce.
|
||
* When a node's nonce changes, it flashes a fading green background — its
|
||
* producer just recomputed it. Driven by the replay player frame-by-frame. */
|
||
recomputedAssetIds?: ReadonlyMap<string, number>
|
||
/** What a run is currently doing to each relation, keyed `asset:<kind>:<path>`
|
||
* like every other per-asset map here.
|
||
* Distinct from `recomputedAssetIds`, which is a one-shot pulse: this is
|
||
* the state a node holds until the run moves it. */
|
||
assetRunStatus?: ReadonlyMap<string, AssetRunState>
|
||
/** Let the wheel zoom the canvas (and swallow the page scroll while doing
|
||
* so). Default true for the full-height editor/player. Set false when the
|
||
* canvas is embedded inline inside a scrollable container, so a wheel
|
||
* gesture over it scrolls the container instead of being captured. */
|
||
scrollZoom?: boolean
|
||
}
|
||
let {
|
||
graph,
|
||
selection,
|
||
onselect,
|
||
onAddScriptForAsset,
|
||
onAddPipelineScript,
|
||
pathPrefix = '',
|
||
defaultPathSuffix = '',
|
||
onRunProducer,
|
||
onRunnableMenuRemove,
|
||
activeRunnable,
|
||
activeRunnableIds,
|
||
runStates,
|
||
onCreateMissingTrigger,
|
||
onEditTrigger,
|
||
onDeleteTrigger,
|
||
onOpenWebhook,
|
||
onOpenDataUpload,
|
||
readyDataUploadPaths,
|
||
hoveredPaths,
|
||
selectedRunPaths,
|
||
panToNodeId,
|
||
validStartPaths,
|
||
onStartBoundedRun,
|
||
boundPick,
|
||
onPickEnd,
|
||
showMinimap = true,
|
||
viewportFitKey = '',
|
||
highlightActiveRun = false,
|
||
recomputedAssetIds,
|
||
assetRunStatus,
|
||
scrollZoom = true
|
||
}: Props = $props()
|
||
|
||
// `${kind}:${path}` ids for the hovered / pinned runs (both script and flow
|
||
// variants, since the run row's kind isn't known here).
|
||
const runIds = (paths: string[] | undefined): Set<string> =>
|
||
new Set((paths ?? []).flatMap((p) => [`script:${p}`, `flow:${p}`]))
|
||
let hoveredRunIdSet = $derived(runIds(hoveredPaths))
|
||
let selectedRunIdSet = $derived(runIds(selectedRunPaths))
|
||
|
||
const ADD_NODE_ID = '__add__'
|
||
|
||
type BuiltEdge = {
|
||
id: string
|
||
source: string
|
||
target: string
|
||
// 'add-anchor' edges connect the + node to every otherwise-root node in
|
||
// the DAG. They force sugiyama to put + at layer 0 (top) and center
|
||
// it horizontally over the roots — same mechanism the flow editor
|
||
// uses for its Trigger node. Filtered out of rendered edges.
|
||
kind:
|
||
| 'lineage-write'
|
||
| 'lineage-read'
|
||
| 'trigger-asset'
|
||
| 'trigger-native'
|
||
| 'add-anchor'
|
||
| 'data-test'
|
||
| 'macro'
|
||
| 'test-dependency'
|
||
| 'dbt-ref'
|
||
unsaved?: boolean
|
||
// Muted read edge: a ducklake/s3 input read every run whose (default)
|
||
// auto cascade trigger is suppressed by `// mute` / `// mute all`.
|
||
// Rendered with a bell-off badge — auto-wiring is the norm, so we mark
|
||
// the read that deliberately does NOT cascade, not every derived edge.
|
||
muted?: boolean
|
||
// Edge from a missing-trigger placeholder — styled red dashed to
|
||
// signal "this script declared `// on kafka` but no trigger row
|
||
// targets it; create one or remove the annotation".
|
||
missing?: boolean
|
||
// Producer's `// data_test` checks, on the write-edge to the
|
||
// materialized asset — rendered as a flask badge on the link.
|
||
data_tests?: NonNullable<AssetGraphResponse['runnables'][number]['data_tests']>
|
||
// Producer's `// column` declared lineage, on the same write-edge —
|
||
// rendered as a columns badge on the link.
|
||
column_lineage?: NonNullable<AssetGraphResponse['runnables'][number]['column_lineage']>
|
||
// Macro-library edge payload: the macros the consumer calls (or the
|
||
// whole lib when pulled in via `// use`) — rendered as a ƒ badge.
|
||
macro_names?: string[]
|
||
via_use?: boolean
|
||
}
|
||
|
||
// Graph-id of the script the user just launched (zero-latency hint),
|
||
// computed once and reused by the optimistic badge + the active-edge set.
|
||
let activeRunnableNodeId = $derived(
|
||
activeRunnable ? `${activeRunnable.kind}:${activeRunnable.path}` : undefined
|
||
)
|
||
|
||
// Lineage edges (parsed r/w usages): writer → asset, asset → reader.
|
||
// Trigger edges (`// on <x>`): asset → script or schedule → script. The
|
||
// lineage subgraph is informational; the trigger subgraph is executable.
|
||
function build(g: AssetGraphResponse) {
|
||
const nodes: Array<{
|
||
id: string
|
||
type: 'asset' | 'runnable' | 'trigger' | 'add' | 'data-test'
|
||
data: any
|
||
}> = []
|
||
const edges: BuiltEdge[] = []
|
||
// Custom (`// data_test <script_path>`) tests are deployed scripts, so we
|
||
// draw each as its own node hanging off the asset it validates (deduped
|
||
// by node id across producers).
|
||
const addedTestNodes = new Set<string>()
|
||
|
||
// A dbt script owns every relation its project materializes. Drawing that
|
||
// as one edge per model buries the lineage that matters — `ref()` between
|
||
// models, and native consumers — under a fan-out that grows with the
|
||
// project, so the association is carried by the model's badge and its
|
||
// hover/click highlight instead. Only the DRAWING is dropped: the
|
||
// producer rows still drive cascade dispatch and "who produced this".
|
||
const dbtRunnableIds = new Set(
|
||
g.runnables.filter((r) => r.dbt).map((r) => `${r.usage_kind}:${r.path}`)
|
||
)
|
||
// Association only — the canvas deliberately draws no edge for it.
|
||
const { ownerByAsset: dbtOwnerByAsset, writesByOwner: dbtWritesByOwner } = dbtAssociations(
|
||
g.runnables,
|
||
g.edges
|
||
)
|
||
// Per-relation dbt description, to tell a project's own declared source
|
||
// from a relation another script materializes.
|
||
const dbtAssetProvenance = new Map(
|
||
g.assets.filter((a) => a.dbt).map((a) => [`asset:${a.kind}:${a.path}`, a.dbt!])
|
||
)
|
||
|
||
const hasAddNode = onAddPipelineScript != null
|
||
if (hasAddNode) {
|
||
nodes.push({
|
||
id: ADD_NODE_ID,
|
||
type: 'add',
|
||
data: {
|
||
onAddPipelineScript: onAddPipelineScript!,
|
||
pathPrefix,
|
||
defaultPathSuffix
|
||
}
|
||
})
|
||
}
|
||
|
||
// Producers per asset, derived from write/rw edges. Used by the
|
||
// asset node to surface a "Run" button on hover/select that fires
|
||
// the upstream script(s). Drafts are *included* (with `unsaved:
|
||
// true`) so the button is visible even before the producer is
|
||
// deployed — the click handler shows a "Save first" toast in that
|
||
// case rather than 404'ing. Hiding the button entirely for unsaved
|
||
// producers made the affordance vanish whenever the user only had
|
||
// drafts in scope, which is the common case in a fresh pipeline.
|
||
const producersByAsset = new Map<
|
||
string,
|
||
Array<{ kind: 'script' | 'flow'; path: string; unsaved?: boolean }>
|
||
>()
|
||
for (const e of g.edges ?? []) {
|
||
const access = e.access_type ?? 'r'
|
||
if (access !== 'w' && access !== 'rw') continue
|
||
const key = `${e.asset_kind}:${e.asset_path}`
|
||
const list = producersByAsset.get(key) ?? []
|
||
if (!list.some((p) => p.kind === e.runnable_kind && p.path === e.runnable_path)) {
|
||
list.push({ kind: e.runnable_kind, path: e.runnable_path, unsaved: e.unsaved })
|
||
}
|
||
producersByAsset.set(key, list)
|
||
}
|
||
|
||
// Downstream subscriber count per producer script. Counts distinct
|
||
// script subscribers (excluding self and flow subs, mirroring the V1
|
||
// dispatch policy) across all assets the script writes. Drives the
|
||
// "Run + trigger N downstream" menu item on RunnableNode and lets the
|
||
// pipeline page short-circuit cascade UX when there's nothing to fan
|
||
// out to. Shared with the dev-run cascade orchestrator (graphTraversal).
|
||
const downstreamByScript = new Map<string, number>()
|
||
// How many of those subscribers are unsaved drafts — the production
|
||
// dispatcher can't reach them, so the cascade menu item flags that the
|
||
// run is a dev orchestration / that deploy is needed for auto-trigger.
|
||
const downstreamUnsavedByScript = new Map<string, number>()
|
||
const unsavedRunnables = new Set(
|
||
(g.runnables ?? []).filter((r) => r.unsaved).map((r) => r.path)
|
||
)
|
||
for (const [path, set] of buildDownstreamMap(g)) {
|
||
downstreamByScript.set(path, set.size)
|
||
downstreamUnsavedByScript.set(path, [...set].filter((s) => unsavedRunnables.has(s)).length)
|
||
}
|
||
// Read-aware downstream presence for the bounded-run gate. The bounded
|
||
// engine treats pure-read `asset → script` edges as downstream, but the
|
||
// subscriber-only `downstreamByScript` above does not — so a start whose
|
||
// only downstream is a pure reader would otherwise be denied the menu
|
||
// item even though its bounded set is non-empty. Mirror of the engine's
|
||
// own adjacency (boundedCascade.buildLineageDownstreamMap).
|
||
const hasLineageDownstream = new Set<string>(buildLineageDownstreamMap(g).keys())
|
||
|
||
// Producers that declare `// data_test` checks, keyed by runnable id — the
|
||
// asset node uses this (plus the producer's run state) to render the
|
||
// data-test outcome badge. Tests only assert on ducklake `// materialize`
|
||
// targets (v1), so guard status is a ducklake-only concept below.
|
||
const producerHasTests = new Set<string>()
|
||
// Producer → its declared `// materialize` target, so a multi-output
|
||
// producer's guard badge lands only on the table its tests assert on —
|
||
// not on its other ducklake outputs. Mirrors the write-edge badge anchor.
|
||
const guardMaterializeTarget = new Map<
|
||
string,
|
||
NonNullable<AssetGraphResponse['runnables'][number]['materialize_target']>
|
||
>()
|
||
for (const r of g.runnables) {
|
||
if (r.data_tests && r.data_tests.length > 0) producerHasTests.add(`${r.usage_kind}:${r.path}`)
|
||
if (r.materialize_target)
|
||
guardMaterializeTarget.set(`${r.usage_kind}:${r.path}`, r.materialize_target)
|
||
}
|
||
|
||
for (const a of g.assets) {
|
||
const assetId = `asset:${a.kind}:${a.path}`
|
||
// Guard/outcome badge inputs: is a producer of this (ducklake) asset
|
||
// test-guarded, and did that guarded producer's latest run fail?
|
||
let dataTestGuarded = false
|
||
let producerFailed = false
|
||
if (a.kind === 'ducklake') {
|
||
for (const p of producersByAsset.get(`${a.kind}:${a.path}`) ?? []) {
|
||
const rid = `${p.kind}:${p.path}`
|
||
if (!producerHasTests.has(rid)) continue
|
||
// Tests assert on the producer's `// materialize` target; when the
|
||
// producer declares one, only that table is guarded (a multi-output
|
||
// producer must not badge its other ducklake writes). No declared
|
||
// target → single-output producer, so its lone ducklake write is it.
|
||
const mt = guardMaterializeTarget.get(rid)
|
||
if (mt && !(mt.kind === a.kind && mt.path === a.path)) continue
|
||
dataTestGuarded = true
|
||
if (runStates?.get(rid)?.status === 'failure') producerFailed = true
|
||
}
|
||
}
|
||
nodes.push({
|
||
id: assetId,
|
||
type: 'asset',
|
||
data: {
|
||
asset_kind: a.kind,
|
||
path: a.path,
|
||
fork_materialization: a.fork_materialization,
|
||
derived_from: a.derived_from,
|
||
dbt: a.dbt,
|
||
onAddScript: onAddScriptForAsset,
|
||
pathPrefix,
|
||
defaultPathSuffix,
|
||
producers: producersByAsset.get(`${a.kind}:${a.path}`) ?? [],
|
||
onRunProducer,
|
||
dataTestGuarded,
|
||
producerFailed,
|
||
// Bumped by the replay player when this asset's producer just
|
||
// recomputed it — the node flashes green and fades.
|
||
recomputePulse: recomputedAssetIds?.get(assetId),
|
||
// What the run in view is doing to this relation right now.
|
||
runStatus: assetRunStatus?.get(assetId)?.status,
|
||
runRowCount: assetRunStatus?.get(assetId)?.rowCount,
|
||
// The dbt project that materializes this relation, related by
|
||
// badge rather than by an edge — so only when that node is on
|
||
// this graph. The run page and the pipeline page both hide it,
|
||
// and passing handlers anyway makes the chip advertise a click
|
||
// that resolves to nothing.
|
||
...(dbtOwnerByAsset.has(assetId)
|
||
? {
|
||
onDbtHover: (on: boolean) => (dbtHoverId = on ? assetId : undefined),
|
||
onDbtSelect: () => {
|
||
// `runnable:<kind>:<path>` — the id shape `build` uses.
|
||
const owner = model.dbtOwnerByAsset.get(assetId)
|
||
const [kind, ...rest] = owner?.split(':') ?? []
|
||
if (kind && rest.length) {
|
||
onselect?.({
|
||
kind: 'runnable',
|
||
runnable_kind: kind as 'script' | 'flow',
|
||
path: rest.join(':')
|
||
})
|
||
}
|
||
}
|
||
}
|
||
: {})
|
||
}
|
||
})
|
||
}
|
||
// Set of `script_path` values for runnables that are still drafts (no
|
||
// DB row yet). Trigger nodes use this to swap "Click to create" for
|
||
// "Click to create (after draft save)" so the user knows the create
|
||
// button is blocked until the script is deployed.
|
||
const unsavedRunnablePaths = new Set<string>()
|
||
for (const r of g.runnables) {
|
||
if (r.unsaved) unsavedRunnablePaths.add(r.path)
|
||
}
|
||
// Producer → its `// data_test` checks, keyed by runnable id, so the
|
||
// write-edge to the materialized asset can carry the test badge: the
|
||
// edge *is* the transformation, and the tests assert on what it produces.
|
||
const producerTests = new Map<
|
||
string,
|
||
NonNullable<AssetGraphResponse['runnables'][number]['data_tests']>
|
||
>()
|
||
for (const r of g.runnables) {
|
||
if (r.data_tests && r.data_tests.length > 0) {
|
||
producerTests.set(`${r.usage_kind}:${r.path}`, r.data_tests)
|
||
}
|
||
}
|
||
// Producer → its `// column` declared lineage, keyed by runnable id, so
|
||
// the write-edge to the materialized asset can carry the columns badge —
|
||
// the edge *is* the transformation, and the lineage describes its output.
|
||
const producerColumnLineage = new Map<
|
||
string,
|
||
NonNullable<AssetGraphResponse['runnables'][number]['column_lineage']>
|
||
>()
|
||
// The `// materialize` target the lineage describes, so the badge lands
|
||
// only on that write-edge (a multi-output script writes several ducklake
|
||
// tables) — mirrors the anchor logic in `buildColumnGraph`.
|
||
const producerMaterializeTarget = new Map<
|
||
string,
|
||
NonNullable<AssetGraphResponse['runnables'][number]['materialize_target']>
|
||
>()
|
||
for (const r of g.runnables) {
|
||
if (r.column_lineage && r.column_lineage.length > 0) {
|
||
producerColumnLineage.set(`${r.usage_kind}:${r.path}`, r.column_lineage)
|
||
}
|
||
if (r.materialize_target) {
|
||
producerMaterializeTarget.set(`${r.usage_kind}:${r.path}`, r.materialize_target)
|
||
}
|
||
}
|
||
for (const r of g.runnables) {
|
||
const rid = `${r.usage_kind}:${r.path}`
|
||
// Optimistic badge: the moment a run is launched from this view
|
||
// `activeRunnable` flips (zero latency), well before the 3s queue
|
||
// poll observes the job. Show "running" immediately, keeping the
|
||
// poll's known run count. Falls back to the polled state (which
|
||
// carries the cascade + the final success/failure) otherwise.
|
||
const polledRunState = runStates?.get(rid)
|
||
let runState = polledRunState
|
||
if (activeRunnableNodeId === rid) {
|
||
runState = { status: 'running' as const, runs: polledRunState?.runs ?? 0 }
|
||
}
|
||
nodes.push({
|
||
id: rid,
|
||
type: 'runnable',
|
||
data: {
|
||
runnable_kind: r.usage_kind,
|
||
path: r.path,
|
||
summary: r.summary,
|
||
in_pipeline: r.in_pipeline ?? false,
|
||
partition_kind: r.partition_kind,
|
||
freshness: r.freshness,
|
||
last_success_at: r.last_success_at,
|
||
tag: r.tag,
|
||
retry: r.retry,
|
||
macros: r.macros,
|
||
dbt: r.dbt,
|
||
onDbtHover: (on: boolean) => (dbtHoverId = on ? rid : undefined),
|
||
unsaved: r.unsaved ?? false,
|
||
// Same dispatch the asset node uses, only routed when the
|
||
// runnable is a script (the page handler short-circuits
|
||
// flows). The run button mirrors the asset-node affordance:
|
||
// click → run with no extra UI clutter.
|
||
onRunSelf:
|
||
r.usage_kind === 'script' && onRunProducer
|
||
? (opts?: { cascade?: boolean }) =>
|
||
onRunProducer({
|
||
kind: 'script',
|
||
path: r.path,
|
||
unsaved: r.unsaved ?? false,
|
||
cascade: opts?.cascade
|
||
})
|
||
: undefined,
|
||
downstreamCount: downstreamByScript.get(r.path) ?? 0,
|
||
downstreamUnsavedCount: downstreamUnsavedByScript.get(r.path) ?? 0,
|
||
runState,
|
||
// Amber-tint this node while it's the transform actively running
|
||
// (replay player only — the live editor keeps its calm styling).
|
||
highlightRunning: highlightActiveRun,
|
||
// Bounded-cascade entrypoint: only valid starts (schedule /
|
||
// manual roots) with downstream get the "Run downstream up
|
||
// to…" menu item.
|
||
onStartBoundedRun:
|
||
r.usage_kind === 'script' &&
|
||
onStartBoundedRun &&
|
||
validStartPaths?.has(r.path) &&
|
||
hasLineageDownstream.has(r.path)
|
||
? () => onStartBoundedRun(r.path)
|
||
: undefined,
|
||
onRequestRemove: onRunnableMenuRemove
|
||
? () =>
|
||
onRunnableMenuRemove({
|
||
runnable_kind: r.usage_kind,
|
||
path: r.path,
|
||
unsaved: r.unsaved ?? false
|
||
})
|
||
: undefined
|
||
}
|
||
})
|
||
}
|
||
|
||
// Read edges of a ducklake/s3 asset with no cascade trigger = muted
|
||
// (`// mute` / `// mute all` opted the default auto trigger out). Gated
|
||
// on pipeline scripts inside the helper (non-pipeline reads never derive).
|
||
const mutedReadKeys = computeMutedReadKeys(g.edges, g.triggers, g.runnables)
|
||
// A dbt script owns every relation of its project. Drawing that as one
|
||
// edge per model buries the lineage that matters (`ref()` between models,
|
||
// and native consumers) under a fan-out that grows with the project — so
|
||
// the association is carried by the node badge and its hover/click
|
||
// highlight instead. Only the DRAWING is dropped: the producer rows still
|
||
// drive cascade dispatch and "who produced this".
|
||
for (const e of g.edges) {
|
||
const runnableId = `${e.runnable_kind}:${e.runnable_path}`
|
||
const assetId = `asset:${e.asset_kind}:${e.asset_path}`
|
||
const access = e.access_type ?? 'r'
|
||
// A dbt project's own relations are related by badge, not by edges: its
|
||
// writes are the fan-out, and its declared sources already reach its
|
||
// models through the `ref()` edges, so both would be noise.
|
||
//
|
||
// A read of a relation ANOTHER script builds is different — that is how
|
||
// two selections of one project compose (decision 6), it carries the
|
||
// cascade, and no `ref()` edge survives the split to stand in for it.
|
||
// Kept, or the two halves render as disconnected islands.
|
||
if (dbtRunnableIds.has(runnableId)) {
|
||
const isOwnSource = dbtAssetProvenance.get(assetId)?.resource_type === 'source'
|
||
if (access === 'w' || access === 'rw' || isOwnSource) continue
|
||
}
|
||
if (access === 'w' || access === 'rw') {
|
||
// Data tests assert on the `// materialize` target, which is always
|
||
// a ducklake asset (v1 enforces this), so only the ducklake
|
||
// write-edge carries the badge / custom-test nodes — a producer's
|
||
// other (e.g. S3/datatable) outputs must not show them.
|
||
const edgeTests = e.asset_kind === 'ducklake' ? producerTests.get(runnableId) : undefined
|
||
// Column lineage describes one materialized output, so the badge
|
||
// lands only on that asset's write-edge: the declared `// materialize`
|
||
// target when known (a multi-output script writes several ducklake
|
||
// tables), else the ducklake write-edge as the unambiguous fallback.
|
||
const matTarget = producerMaterializeTarget.get(runnableId)
|
||
const isOutputEdge = matTarget
|
||
? e.asset_kind === matTarget.kind && e.asset_path === matTarget.path
|
||
: e.asset_kind === 'ducklake'
|
||
const edgeColumnLineage = isOutputEdge ? producerColumnLineage.get(runnableId) : undefined
|
||
edges.push({
|
||
id: `prod:${runnableId}->${assetId}`,
|
||
source: runnableId,
|
||
target: assetId,
|
||
kind: 'lineage-write',
|
||
unsaved: e.unsaved,
|
||
data_tests: edgeTests,
|
||
column_lineage: edgeColumnLineage
|
||
})
|
||
// Each custom (`// data_test <script_path>`) test → its own node
|
||
// below the asset it validates, with a dashed "tests" edge.
|
||
for (const t of edgeTests ?? []) {
|
||
if (t.type !== 'custom') continue
|
||
const testNodeId = `datatest:${assetId}:${t.path}`
|
||
if (!addedTestNodes.has(testNodeId)) {
|
||
addedTestNodes.add(testNodeId)
|
||
nodes.push({ id: testNodeId, type: 'data-test', data: { path: t.path } })
|
||
}
|
||
edges.push({
|
||
id: `test:${assetId}->${testNodeId}`,
|
||
source: assetId,
|
||
target: testNodeId,
|
||
kind: 'data-test',
|
||
unsaved: e.unsaved
|
||
})
|
||
}
|
||
}
|
||
if (access === 'r' || access === 'rw') {
|
||
edges.push({
|
||
id: `cons:${assetId}->${runnableId}`,
|
||
source: assetId,
|
||
target: runnableId,
|
||
kind: 'lineage-read',
|
||
unsaved: e.unsaved,
|
||
// Only a pure `'r'` read can be muted; `'rw'` is a self-read.
|
||
muted:
|
||
access === 'r' &&
|
||
mutedReadKeys.has(
|
||
`${e.asset_kind}:${e.asset_path}->${e.runnable_kind}:${e.runnable_path}`
|
||
)
|
||
})
|
||
}
|
||
}
|
||
|
||
// Macro-library → consumer edges (runnable→runnable, unlike the
|
||
// asset-mediated lineage above). Endpoints must exist as nodes — an
|
||
// undeployed `// use` target without a draft has none, so its edge is
|
||
// skipped (the annotation still shows in the script body itself).
|
||
const runnableNodeIds = new Set(g.runnables.map((r) => `${r.usage_kind}:${r.path}`))
|
||
for (const me of g.macro_edges ?? []) {
|
||
const libId = `script:${me.lib_path}`
|
||
const consumerId = `script:${me.consumer_path}`
|
||
if (!runnableNodeIds.has(libId) || !runnableNodeIds.has(consumerId)) continue
|
||
edges.push({
|
||
id: `macro:${libId}->${consumerId}`,
|
||
source: libId,
|
||
target: consumerId,
|
||
kind: 'macro',
|
||
unsaved: me.unsaved,
|
||
macro_names: me.macro_names,
|
||
via_use: me.via_use
|
||
})
|
||
}
|
||
|
||
// Data-test ordering edges (producer → tested script): the referenced
|
||
// asset must be materialized before the test runs, so the cascade orders
|
||
// the producer first. Rendered as a dashed "must run after" link, distinct
|
||
// from data flow — the tested script doesn't consume the asset's rows.
|
||
for (const te of g.test_edges ?? []) {
|
||
const producerId = `${te.producer_kind}:${te.producer_path}`
|
||
const testedId = `${te.runnable_kind}:${te.runnable_path}`
|
||
if (!runnableNodeIds.has(producerId) || !runnableNodeIds.has(testedId)) continue
|
||
edges.push({
|
||
id: `testdep:${producerId}->${testedId}`,
|
||
source: producerId,
|
||
target: testedId,
|
||
kind: 'test-dependency'
|
||
})
|
||
}
|
||
|
||
// dbt `ref()` lineage: model → model inside one project. The dbt script
|
||
// writes every one of them, so without these the canvas shows a flat
|
||
// fan-out from the script and loses the project's actual shape.
|
||
const assetNodeIds = new Set(g.assets.map((a) => `asset:${a.kind}:${a.path}`))
|
||
for (const de of g.dbt_edges ?? []) {
|
||
const from = `asset:dbt:${de.from_asset_path}`
|
||
const to = `asset:dbt:${de.to_asset_path}`
|
||
if (!assetNodeIds.has(from) || !assetNodeIds.has(to)) continue
|
||
edges.push({ id: `dbtref:${from}->${to}`, source: from, target: to, kind: 'dbt-ref' })
|
||
}
|
||
|
||
// Non-asset triggers (schedule + native) are rendered as source nodes
|
||
// above the pipeline script. Real (non-missing) nodes are
|
||
// deduplicated per (kind, ref) tuple so a single schedule shared
|
||
// across multiple scripts shows as one node with N outgoing edges.
|
||
// "missing" placeholders are scoped per-(kind, script) — each script
|
||
// gets its own placeholder so the prompt "create / delete" tells
|
||
// the user which script the annotation lives on.
|
||
const triggerSourceNodes = new Map<
|
||
string,
|
||
{
|
||
allUnsaved: boolean
|
||
kind: TriggerNodeKind
|
||
ref: string
|
||
missing: boolean
|
||
draft: boolean
|
||
// First target script (drives the per-script create/edit flows).
|
||
runnable_path?: string
|
||
// Every target script: a single (kind, ref) — e.g. one schedule —
|
||
// can be shared across scripts and dedupes to one node with N
|
||
// edges. The bounded-run action must consider all of them, not
|
||
// just the first.
|
||
runnable_paths: string[]
|
||
}
|
||
>()
|
||
function recordSourceTrigger(
|
||
id: string,
|
||
kind: TriggerNodeKind,
|
||
ref: string,
|
||
unsaved: boolean,
|
||
missing: boolean,
|
||
draft: boolean,
|
||
runnable_path?: string
|
||
) {
|
||
const prev = triggerSourceNodes.get(id)
|
||
if (!prev) {
|
||
triggerSourceNodes.set(id, {
|
||
allUnsaved: unsaved,
|
||
kind,
|
||
ref,
|
||
missing,
|
||
draft,
|
||
runnable_path,
|
||
runnable_paths: runnable_path ? [runnable_path] : []
|
||
})
|
||
} else {
|
||
prev.allUnsaved = prev.allUnsaved && unsaved
|
||
if (runnable_path && !prev.runnable_paths.includes(runnable_path)) {
|
||
prev.runnable_paths.push(runnable_path)
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const t of g.triggers ?? []) {
|
||
const runnableId = `${t.runnable_kind}:${t.runnable_path}`
|
||
if (t.trigger_kind === 'asset') {
|
||
const assetId = `asset:${t.asset_kind}:${t.asset_path}`
|
||
edges.push({
|
||
id: `trig-a:${assetId}->${runnableId}`,
|
||
source: assetId,
|
||
target: runnableId,
|
||
kind: 'trigger-asset',
|
||
unsaved: t.unsaved
|
||
})
|
||
continue
|
||
}
|
||
// Rowless kinds (webhook, data_upload) never have a trigger row by
|
||
// design — webhook is an implicit endpoint, data_upload is a
|
||
// UI-first entry point. They're per-script source nodes, not
|
||
// "missing" placeholders, so suppress the missing/red treatment on
|
||
// both node and edge while keeping a per-script identity.
|
||
const isRowless = t.trigger_kind === 'webhook' || t.trigger_kind === 'data_upload'
|
||
const isMissing = (t as any).missing === true && !isRowless
|
||
// Attached native: trigger row path. Rowless: per-script ref so each
|
||
// script gets its own node. Missing: synthesize a per-script ref so
|
||
// each placeholder is its own node ("missing kafka on f/foo/bar").
|
||
// Schedule joins the native family — its ref is the schedule row's
|
||
// path, same shape.
|
||
const ref = isRowless
|
||
? `rowless:${t.runnable_path}`
|
||
: isMissing
|
||
? `missing:${t.runnable_path}`
|
||
: ((t as any).path ?? '')
|
||
const sourceId = `trigger:${t.trigger_kind}:${ref}`
|
||
recordSourceTrigger(
|
||
sourceId,
|
||
t.trigger_kind,
|
||
ref,
|
||
!!t.unsaved,
|
||
isMissing,
|
||
t.draft === true,
|
||
// Always thread the target script so the trigger node can
|
||
// reach back to it — drives both the missing-trigger "create"
|
||
// flow and the attached-trigger "edit" flow's script-path
|
||
// lock. Previously only set for missing, which silently
|
||
// disabled `canEdit` (and the resulting click affordance)
|
||
// on every attached native trigger.
|
||
t.runnable_path
|
||
)
|
||
edges.push({
|
||
id: `trig-${t.trigger_kind}:${sourceId}->${runnableId}`,
|
||
source: sourceId,
|
||
target: runnableId,
|
||
kind: 'trigger-native',
|
||
unsaved: t.unsaved,
|
||
missing: isMissing
|
||
})
|
||
}
|
||
for (const [id, info] of triggerSourceNodes) {
|
||
// Bounded-run targets among this trigger's scripts: valid starts that
|
||
// also have read-aware downstream. A shared schedule may have several
|
||
// targets; only offer the action when exactly one qualifies, so the
|
||
// run starts from an unambiguous script (rather than the arbitrary
|
||
// first-seen one). Multi-eligible nodes suppress it rather than guess.
|
||
const eligibleStarts = onStartBoundedRun
|
||
? info.runnable_paths.filter((p) => validStartPaths?.has(p) && hasLineageDownstream.has(p))
|
||
: []
|
||
nodes.push({
|
||
id,
|
||
type: 'trigger',
|
||
data: {
|
||
kind: info.kind,
|
||
ref: info.ref,
|
||
unsaved: info.allUnsaved,
|
||
missing: info.missing,
|
||
draft: info.draft,
|
||
runnable_path: info.runnable_path,
|
||
runnable_unsaved: info.runnable_path
|
||
? unsavedRunnablePaths.has(info.runnable_path)
|
||
: false,
|
||
// data_upload nodes go green once a file is staged for their
|
||
// target script (see readyDataUploadPaths / page dataUploadArgs).
|
||
ready: info.runnable_path
|
||
? (readyDataUploadPaths?.has(info.runnable_path) ?? false)
|
||
: false,
|
||
onCreateMissingTrigger,
|
||
onEditTrigger,
|
||
onDeleteTrigger,
|
||
onOpenWebhook,
|
||
onOpenDataUpload,
|
||
// View-mode bounded-run entry: offer it on the trigger node
|
||
// when exactly one target script is a valid start with
|
||
// downstream (see eligibleStarts above).
|
||
onStartBoundedRun:
|
||
onStartBoundedRun && eligibleStarts.length === 1
|
||
? () => onStartBoundedRun(eligibleStarts[0])
|
||
: undefined
|
||
}
|
||
})
|
||
}
|
||
|
||
// Anchor the + to layer 0 by making it a parent of every node that
|
||
// would otherwise be a root (no incoming edge), *including* schedule
|
||
// nodes. Must run AFTER all real edges are added so we catch the
|
||
// current set of roots. Sugiyama then places + on layer 0 and
|
||
// centers it over whatever's below.
|
||
if (hasAddNode) {
|
||
const hasIncoming = new Set<string>()
|
||
for (const e of edges) hasIncoming.add(e.target)
|
||
for (const n of nodes) {
|
||
if (n.id === ADD_NODE_ID) continue
|
||
if (!hasIncoming.has(n.id)) {
|
||
edges.push({
|
||
id: `add-anchor:${n.id}`,
|
||
source: ADD_NODE_ID,
|
||
target: n.id,
|
||
kind: 'add-anchor'
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
return { nodes, edges, dbtOwnerByAsset, dbtWritesByOwner }
|
||
}
|
||
|
||
let model = $derived(build(graph))
|
||
|
||
// dbt association, surfaced by emphasis instead of edges. Hovering a model's
|
||
// dbt badge lights up the project node that materializes it; hovering the
|
||
// project node lights up every model it owns. Clicking the badge selects the
|
||
// project node, so the association survives the pointer leaving.
|
||
let dbtHoverId = $state<string | undefined>(undefined)
|
||
let dbtEmphasisIds = $derived.by(() => {
|
||
if (!dbtHoverId) return new Set<string>()
|
||
const owned = model.dbtWritesByOwner.get(dbtHoverId)
|
||
if (owned) return new Set<string>([dbtHoverId, ...owned])
|
||
const owner = model.dbtOwnerByAsset.get(dbtHoverId)
|
||
return owner ? new Set<string>([dbtHoverId, owner]) : new Set<string>()
|
||
})
|
||
|
||
let selectedId = $derived.by(() => {
|
||
if (!selection) return undefined
|
||
return selection.kind === 'asset'
|
||
? `asset:${selection.asset_kind}:${selection.path}`
|
||
: `${selection.runnable_kind}:${selection.path}`
|
||
})
|
||
|
||
// Pane width drives horizontal centering (same pattern FlowGraphV2 uses).
|
||
// Bound on the outer wrapper; updates on pane resize via $state.
|
||
let paneWidth = $state(800)
|
||
|
||
// Compute layout positions strictly off the model — selection changing
|
||
// must NOT trigger a re-layout. Sugiyama's decross output depends on
|
||
// the order in which nodes/edges arrive, so two builds with identical
|
||
// topology but different insertion order can produce different layer
|
||
// orderings (selecting a node briefly re-emits `liveAnnotations`-
|
||
// driven trigger overlays in a different order, etc.). Sorting both
|
||
// arrays by their stable identity (node id, edge source→target)
|
||
// gives sugiyama a topology+path-deterministic input — same nodes +
|
||
// edges + paths → same layout. Renames *do* change the layout for
|
||
// the renamed entry, since its id moves in the sort, but that's
|
||
// expected: a rename is a path change, which is part of the input.
|
||
// A script with rw access to an asset yields both a write (script → asset)
|
||
// and a read/trigger (asset → script) edge — a 2-cycle. The layout resolves
|
||
// it producer-above-asset by omitting the backward direction from its
|
||
// input; the rendered edges are untouched (both arrows still drawn).
|
||
let writeEdgePairs = $derived(
|
||
new Set(
|
||
model.edges.filter((e) => e.kind === 'lineage-write').map((e) => `${e.source}\n${e.target}`)
|
||
)
|
||
)
|
||
let layoutInput = $derived({
|
||
nodes: model.nodes
|
||
.map((n) => ({ id: n.id, data: n.data }))
|
||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
||
edges: model.edges
|
||
.filter(
|
||
(e) =>
|
||
!(
|
||
(e.kind === 'lineage-read' || e.kind === 'trigger-asset') &&
|
||
writeEdgePairs.has(`${e.target}\n${e.source}`)
|
||
)
|
||
)
|
||
.map((e) => ({ source: e.source, target: e.target }))
|
||
.sort((a, b) =>
|
||
a.source === b.source
|
||
? a.target < b.target
|
||
? -1
|
||
: a.target > b.target
|
||
? 1
|
||
: 0
|
||
: a.source < b.source
|
||
? -1
|
||
: 1
|
||
)
|
||
})
|
||
let layoutPositions = $derived(layoutAssetGraph(layoutInput, ADD_NODE_ID))
|
||
|
||
let positionedNodes = $derived.by(() => {
|
||
// Compute bbox width from layout; shift every x so the graph is
|
||
// horizontally centered inside the pane. y is untouched so layer 0
|
||
// sits at the top of the viewport (matches flow editor's Trigger
|
||
// placement — no fitView reshuffling).
|
||
let minX = Infinity
|
||
let maxX = -Infinity
|
||
for (const p of layoutPositions.values()) {
|
||
if (p.x < minX) minX = p.x
|
||
if (p.x > maxX) maxX = p.x
|
||
}
|
||
const bboxWidth = isFinite(minX) ? maxX - minX : 0
|
||
const xCenter = paneWidth / 2 - bboxWidth / 2
|
||
return model.nodes.map<Node>((n) => {
|
||
const p = layoutPositions.get(n.id) ?? { x: 0, y: 0 }
|
||
// Compensate for the + node being narrower than its layout slot
|
||
// so it visually centers over the node(s) below.
|
||
const xShift = n.id === ADD_NODE_ID ? (NODE.width - ADD_NODE_WIDTH) / 2 : 0
|
||
// Activity-panel emphasis (purely visual rings, kept off `selected`
|
||
// which swaps the details pane). Hover wins over pin so the cursor
|
||
// always tracks.
|
||
const runClass = hoveredRunIdSet.has(n.id)
|
||
? 'wm-run-hover'
|
||
: selectedRunIdSet.has(n.id)
|
||
? 'wm-run-selected'
|
||
: undefined
|
||
// Asset border matching the connecting edge's hue (only when no run
|
||
// class applies — a node is either a runnable or an asset).
|
||
const assetEmph = assetEmphasis.get(n.id)
|
||
const assetClass =
|
||
assetEmph === 'output'
|
||
? 'wm-asset-output'
|
||
: assetEmph === 'input'
|
||
? 'wm-asset-input'
|
||
: undefined
|
||
// Bounded-run pick overlay takes precedence over the activity rings
|
||
// (it's a transient, modal selection). start ring > end mark > in-set
|
||
// ring > eligible (clickable, no style) > dimmed (out of reach).
|
||
let boundClass: string | undefined
|
||
if (boundPick && n.type !== 'add' && n.type !== 'trigger') {
|
||
if (n.id === boundPick.start) boundClass = 'wm-bound-start'
|
||
else if (boundPick.ends.has(n.id)) boundClass = 'wm-bound-end'
|
||
else if (boundPick.bounded.has(n.id)) boundClass = 'wm-bound-in'
|
||
else if (!boundPick.eligible.has(n.id)) boundClass = 'wm-bound-dim'
|
||
}
|
||
const dbtClass = dbtEmphasisIds.has(n.id) ? 'wm-dbt-linked' : undefined
|
||
return {
|
||
id: n.id,
|
||
type: n.type,
|
||
position: { x: p.x + xCenter + xShift, y: p.y + 40 },
|
||
data: n.data,
|
||
class: boundClass ?? dbtClass ?? runClass ?? assetClass,
|
||
selected: n.id === selectedId,
|
||
// All nodes non-draggable: the layout is sugiyama-computed,
|
||
// dragging would fight the reactive re-layout. Selection is
|
||
// still allowed on asset/runnable (not on the + or schedules)
|
||
// for the details-pane click-through. Trigger nodes opt out
|
||
// of selection but TriggerNode itself sets `pointer-events: auto`
|
||
// on its inner box so the edit/create button still receives
|
||
// clicks despite svelte-flow's wrapper-level pointer-events: none.
|
||
draggable: false,
|
||
selectable: n.id !== ADD_NODE_ID && n.type !== 'trigger'
|
||
}
|
||
})
|
||
})
|
||
|
||
// Graph-ids of every runnable that's executing right now: the polled
|
||
// in-flight/just-finished set (cascade) plus the zero-latency hint for
|
||
// the script the user just launched. Edges into/out of any of these get
|
||
// the animated stroke — animation stays reserved as an "is happening
|
||
// now" signal and the static graph stays quiet when nothing runs.
|
||
let activeRunnableIdSet = $derived(
|
||
new Set<string>([
|
||
...(activeRunnableIds ?? []),
|
||
...(activeRunnableNodeId ? [activeRunnableNodeId] : [])
|
||
])
|
||
)
|
||
// Node ids emphasized from the Activity panel (hovered or pinned runs).
|
||
// Their incident edges get the same flow animation as a live run, so
|
||
// hovering a run (or a whole group) traces its data path through the graph.
|
||
let emphasizedRunIdSet = $derived(new Set<string>([...hoveredRunIdSet, ...selectedRunIdSet]))
|
||
// Asset nodes adjacent to an emphasized run, tagged by edge family so their
|
||
// border matches the edge hue: `output` (a write, blue) / `input` (a read or
|
||
// asset-trigger, gray). Output wins if an asset is both.
|
||
let assetEmphasis = $derived.by<Map<string, 'input' | 'output'>>(() => {
|
||
const m = new Map<string, 'input' | 'output'>()
|
||
if (emphasizedRunIdSet.size === 0) return m
|
||
for (const e of model.edges) {
|
||
if (e.kind === 'lineage-write' && emphasizedRunIdSet.has(e.source)) {
|
||
m.set(e.target, 'output')
|
||
} else if (
|
||
(e.kind === 'lineage-read' || e.kind === 'trigger-asset') &&
|
||
emphasizedRunIdSet.has(e.target) &&
|
||
m.get(e.source) !== 'output'
|
||
) {
|
||
m.set(e.source, 'input')
|
||
}
|
||
}
|
||
return m
|
||
})
|
||
// Node centers (handle x ≈ left + width/2; y = top) for obstacle-aware edge
|
||
// routing. Computed off the laid-out positions, so it only re-runs when the
|
||
// graph relayouts — never per frame.
|
||
let nodeCenters = $derived(
|
||
positionedNodes
|
||
.filter((n) => n.id !== ADD_NODE_ID)
|
||
.map((n) => ({ id: n.id, cx: n.position.x + NODE.width / 2, cy: n.position.y }))
|
||
)
|
||
// Detour lane for an edge whose straight run would pass over an unrelated
|
||
// node (the failure the same-column gutter can't see). For each node
|
||
// strictly between the endpoints' rows, sample the straight line at that
|
||
// row; if the node sits under it, route around the obstacle on the side the
|
||
// edge is already heading. Returns the outermost lane x clearing every
|
||
// crossed node, or undefined when the corridor is clear. O(nodes) per edge.
|
||
const HALF_W = NODE.width / 2
|
||
const ROUTE_PAD = NODE.gap.horizontal / 2
|
||
function detourForEdge(sourceId: string, targetId: string): number | undefined {
|
||
const s = nodeCenters.find((n) => n.id === sourceId)
|
||
const t = nodeCenters.find((n) => n.id === targetId)
|
||
if (!s || !t || s.cy === t.cy) return undefined
|
||
const dyTot = t.cy - s.cy
|
||
let lane: number | undefined
|
||
for (const n of nodeCenters) {
|
||
if (n.id === sourceId || n.id === targetId) continue
|
||
// strictly between the two rows
|
||
if ((n.cy - s.cy) / dyTot <= 0.01 || (n.cy - s.cy) / dyTot >= 0.99) continue
|
||
const edgeX = s.cx + (t.cx - s.cx) * ((n.cy - s.cy) / dyTot)
|
||
if (Math.abs(edgeX - n.cx) >= HALF_W + 8) continue
|
||
// Crossed: a lane just outside this node, toward the target side.
|
||
const side = t.cx >= n.cx ? 1 : -1
|
||
const candidate = n.cx + side * (HALF_W + ROUTE_PAD)
|
||
// Keep the outermost lane so one detour clears every obstacle.
|
||
if (lane == undefined || Math.abs(candidate - s.cx) > Math.abs(lane - s.cx)) lane = candidate
|
||
}
|
||
return lane
|
||
}
|
||
|
||
let flowEdges = $derived.by(() =>
|
||
model.edges
|
||
// Anchor edges are layout-only.
|
||
.filter((e) => e.kind !== 'add-anchor')
|
||
.map<Edge>((e) => {
|
||
const touchesActiveRunnable =
|
||
activeRunnableIdSet.size > 0 &&
|
||
(activeRunnableIdSet.has(e.source) || activeRunnableIdSet.has(e.target))
|
||
// Edge incident to an Activity-panel hovered/pinned run — animate
|
||
// it too so the row↔graph link is unmistakable.
|
||
const touchesEmphasis =
|
||
emphasizedRunIdSet.size > 0 &&
|
||
(emphasizedRunIdSet.has(e.source) || emphasizedRunIdSet.has(e.target))
|
||
const flowAnimated = touchesActiveRunnable || touchesEmphasis
|
||
let style: string
|
||
let animated = false
|
||
let markerColor: string | undefined = undefined
|
||
let strokeDasharray: string | undefined = undefined
|
||
let label: string | undefined = undefined
|
||
let labelStyle: string | undefined = undefined
|
||
// Two edge families, two hues: edges *out of scripts* (writes
|
||
// — data being produced) carry the luminance-blue accent and
|
||
// pair with the blue asset icons; edges *out of data* (reads
|
||
// + asset triggers into scripts) stay gray, with dashes /
|
||
// labels for the trigger variants. Red is reserved for the
|
||
// missing-trigger branch below; the run animation itself is
|
||
// the "happening now" signal.
|
||
switch (e.kind) {
|
||
case 'lineage-write':
|
||
style = 'stroke: rgb(59 130 246); stroke-width: 2px;'
|
||
animated = flowAnimated
|
||
markerColor = 'rgb(59 130 246)'
|
||
break
|
||
case 'lineage-read':
|
||
style = 'stroke: rgb(156 163 175); stroke-width: 1.25px;'
|
||
animated = flowAnimated
|
||
break
|
||
case 'data-test':
|
||
// Asset → its custom-test script: dashed, muted, no run
|
||
// animation (the test isn't a producing step).
|
||
style = 'stroke: rgb(156 163 175); stroke-width: 1.25px;'
|
||
strokeDasharray = '4 3'
|
||
markerColor = 'rgb(156 163 175)'
|
||
break
|
||
case 'trigger-asset':
|
||
style = 'stroke: rgb(107 114 128); stroke-width: 2px;'
|
||
animated = flowAnimated
|
||
markerColor = 'rgb(107 114 128)'
|
||
label = 'triggers'
|
||
labelStyle = 'fill: rgb(107 114 128); font-size: 10px; font-weight: 600;'
|
||
break
|
||
case 'trigger-native':
|
||
style = 'stroke: rgb(107 114 128); stroke-width: 2px;'
|
||
strokeDasharray = '6 3'
|
||
markerColor = 'rgb(107 114 128)'
|
||
label = 'triggers'
|
||
labelStyle = 'fill: rgb(107 114 128); font-size: 10px; font-weight: 600;'
|
||
break
|
||
case 'macro':
|
||
// Library → consumer: violet dashed, visually apart from both
|
||
// lineage (blue/gray solid) and trigger (gray dashed) families —
|
||
// it's a code dependency, not data flow or execution.
|
||
style = 'stroke: rgb(139 92 246); stroke-width: 1.25px;'
|
||
strokeDasharray = '5 3'
|
||
markerColor = 'rgb(139 92 246)'
|
||
label = e.via_use ? 'uses lib' : 'macros'
|
||
labelStyle = 'fill: rgb(139 92 246); font-size: 10px; font-weight: 600;'
|
||
break
|
||
case 'test-dependency':
|
||
// Producer → tested script: amber dashed ordering link. Not
|
||
// data flow (blue/gray) nor execution trigger (gray "triggers")
|
||
// — it only says "the test needs this asset to exist first".
|
||
style = 'stroke: rgb(217 119 6); stroke-width: 1.25px;'
|
||
strokeDasharray = '5 3'
|
||
markerColor = 'rgb(217 119 6)'
|
||
label = 'test needs'
|
||
labelStyle = 'fill: rgb(217 119 6); font-size: 10px; font-weight: 600;'
|
||
break
|
||
case 'dbt-ref':
|
||
// model → model inside one dbt project. Orange, matching the
|
||
// dbt badges, and dashed because the edge is dbt's own lineage
|
||
// rather than a Windmill read/write the cascade acts on.
|
||
style = 'stroke: rgb(234 88 12); stroke-width: 1.25px;'
|
||
strokeDasharray = '4 3'
|
||
markerColor = 'rgb(234 88 12)'
|
||
label = 'ref'
|
||
labelStyle = 'fill: rgb(234 88 12); font-size: 10px; font-weight: 600;'
|
||
// Same rule the pipeline uses for a running script: the edges
|
||
// touching what is happening animate. Here the unit of work is
|
||
// the model, so the edges feeding the one dbt is building move,
|
||
// and the flow reads in DAG order as it advances.
|
||
animated = assetRunStatus?.get(e.target)?.status === 'running'
|
||
break
|
||
default:
|
||
style = ''
|
||
}
|
||
// Unsaved edges (trigger or lineage) get a distinct dashed
|
||
// pattern + dimmed opacity so the user sees they're live-
|
||
// parsed, not persisted. Lineage edges from base graph data
|
||
// don't carry `unsaved`, only those synthesized by the draft
|
||
// overlay (e.g. the random output asset).
|
||
//
|
||
// `animated` is intentionally NOT cleared here: a live-parsed
|
||
// output edge still belongs to a script that can be run, and
|
||
// the run feedback (flow animation toward its output assets)
|
||
// is exactly what the user expects while it executes. The
|
||
// dashed+dimmed treatment already conveys "not persisted";
|
||
// it composes fine with the active-run animation.
|
||
if (e.unsaved) {
|
||
strokeDasharray = '3 3'
|
||
style = `${style} opacity: 0.7;`
|
||
if (label) label = `${label} (unsaved)`
|
||
}
|
||
// Missing-trigger edge: overrides the per-kind stroke colour
|
||
// with red so the entire "annotated but no row" branch reads
|
||
// as broken at a glance. Composes with `unsaved` if both
|
||
// (red dashed dimmed — fresh draft annotation that also has
|
||
// no matching row, which is the common case).
|
||
if (e.missing) {
|
||
style = 'stroke: rgb(239 68 68); stroke-width: 2px;'
|
||
strokeDasharray = '3 3'
|
||
markerColor = 'rgb(239 68 68)'
|
||
label = 'missing trigger'
|
||
labelStyle = 'fill: rgb(239 68 68); font-size: 10px; font-weight: 600;'
|
||
}
|
||
if (strokeDasharray) {
|
||
style = `${style} stroke-dasharray: ${strokeDasharray};`
|
||
}
|
||
return {
|
||
id: e.id,
|
||
source: e.source,
|
||
target: e.target,
|
||
type: 'asset',
|
||
data: {
|
||
detourX: detourForEdge(e.source, e.target),
|
||
// Data-test badge on the producer→asset write-edge. The
|
||
// producer's last-run status (the script fails if any test
|
||
// fails) tints it green/red; neutral until it has run.
|
||
data_tests: e.data_tests,
|
||
testsRunStatus: e.data_tests?.length ? runStates?.get(e.source)?.status : undefined,
|
||
// Column-lineage badge on the same write-edge (the link is the
|
||
// transformation whose output columns the lineage describes).
|
||
column_lineage: e.column_lineage,
|
||
// Macro-edge badge: which of the library's macros the consumer
|
||
// calls (all of them when pulled in via `// use`).
|
||
macro_names: e.macro_names,
|
||
via_use: e.via_use,
|
||
// Muted read edge — bell-off badge on the read link.
|
||
muted: e.muted
|
||
},
|
||
animated,
|
||
label,
|
||
labelStyle,
|
||
labelBgStyle: label ? 'fill: rgb(255 255 255 / 0.9);' : undefined,
|
||
style,
|
||
markerEnd: {
|
||
type: MarkerType.ArrowClosed,
|
||
width: 14,
|
||
height: 14,
|
||
color: markerColor
|
||
}
|
||
}
|
||
})
|
||
)
|
||
|
||
let nodes = $state.raw<Node[]>([])
|
||
let edges = $state.raw<Edge[]>([])
|
||
$effect(() => {
|
||
nodes = positionedNodes
|
||
})
|
||
$effect(() => {
|
||
edges = flowEdges
|
||
})
|
||
|
||
const nodeTypes = {
|
||
asset: AssetNode as any,
|
||
runnable: RunnableNode as any,
|
||
trigger: TriggerNode as any,
|
||
add: AddNode as any,
|
||
'data-test': DataTestNode as any
|
||
}
|
||
|
||
const edgeTypes = {
|
||
asset: AssetGraphEdge as any
|
||
}
|
||
|
||
function handleNodeClick({ node }: { node: Node }) {
|
||
// Bounded-run pick mode intercepts clicks: an eligible (downstream)
|
||
// node toggles as an end bound; the start, dimmed nodes, and
|
||
// triggers/+ are inert. Selection (details pane) is suppressed so the
|
||
// modal pick stays focused.
|
||
if (boundPick && onPickEnd) {
|
||
if (node.type !== 'asset' && node.type !== 'runnable') return
|
||
if (node.id === boundPick.start) return
|
||
if (!boundPick.eligible.has(node.id)) return
|
||
onPickEnd(node.id)
|
||
return
|
||
}
|
||
if (!onselect) return
|
||
const data = node.data as any
|
||
if (node.type === 'asset') {
|
||
onselect({ kind: 'asset', asset_kind: data.asset_kind, path: data.path })
|
||
} else if (node.type === 'runnable') {
|
||
onselect({ kind: 'runnable', runnable_kind: data.runnable_kind, path: data.path })
|
||
} else if (node.type === 'data-test') {
|
||
// A custom test is a deployed script — open it like any runnable.
|
||
onselect({ kind: 'runnable', runnable_kind: 'script', path: data.path })
|
||
}
|
||
// 'schedule' doesn't produce a selection.
|
||
}
|
||
|
||
/// Clicking empty canvas clears the selection. `onselect` already promises it
|
||
/// can receive `undefined`, it just never did — so a details pane opened from
|
||
/// a node had no way to close by clicking off it.
|
||
///
|
||
/// Read off the click TARGET rather than SvelteFlow's `onpaneclick`, which
|
||
/// does not fire here: what counts as "empty" is everything that is not a
|
||
/// node, an edge or one of the canvas's own controls, and that is a question
|
||
/// about the DOM the click landed on. In the CAPTURE phase, because the flow
|
||
/// stops the click before it bubbles back out to this wrapper.
|
||
function handleBackgroundClick(event: MouseEvent) {
|
||
if (boundPick || !onselect) return
|
||
const t = event.target as HTMLElement | null
|
||
if (
|
||
t?.closest(
|
||
'.svelte-flow__node, .svelte-flow__edge, .svelte-flow__controls, .svelte-flow__minimap, .svelte-flow__panel'
|
||
)
|
||
) {
|
||
return
|
||
}
|
||
onselect(undefined)
|
||
}
|
||
</script>
|
||
|
||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||
<div class="w-full h-full relative" bind:clientWidth={paneWidth} onclickcapture={handleBackgroundClick}>
|
||
<SvelteFlow
|
||
{nodes}
|
||
{edges}
|
||
{nodeTypes}
|
||
{edgeTypes}
|
||
minZoom={0.2}
|
||
maxZoom={1.6}
|
||
nodesDraggable={false}
|
||
nodesConnectable={false}
|
||
elementsSelectable
|
||
zoomOnScroll={scrollZoom}
|
||
preventScrolling={scrollZoom}
|
||
zoomOnDoubleClick={false}
|
||
connectionLineType={ConnectionLineType.SmoothStep}
|
||
defaultEdgeOptions={{ type: 'asset' }}
|
||
proOptions={{ hideAttribution: true }}
|
||
onnodeclick={handleNodeClick}
|
||
--background-color={false}
|
||
>
|
||
<div class="absolute inset-0 !bg-surface-secondary h-full"></div>
|
||
<InitialFitView {nodes} fitKey={viewportFitKey} />
|
||
<PanToNode targetId={panToNodeId} {nodes} />
|
||
<Controls position="top-right" orientation="horizontal" showLock={false} class="!mr-10" />
|
||
{#if showMinimap}
|
||
<!-- Node hues mirror the canvas: blue asset cards, amber triggers,
|
||
bordered neutral script cards. Visible strokes + rounded corners +
|
||
a bordered container + the outlined viewport mask are what make
|
||
this read as a minimap instead of a loading skeleton. -->
|
||
<MiniMap
|
||
pannable
|
||
zoomable
|
||
class="!bg-surface !mb-10 rounded-md border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden"
|
||
nodeBorderRadius={12}
|
||
nodeStrokeWidth={6}
|
||
nodeColor={(n) =>
|
||
n.type === 'asset'
|
||
? 'rgb(59 130 246 / 0.3)'
|
||
: n.type === 'trigger'
|
||
? 'rgb(245 158 11 / 0.3)'
|
||
: 'rgb(148 163 184 / 0.15)'}
|
||
nodeStrokeColor={(n) =>
|
||
n.type === 'asset'
|
||
? 'rgb(59 130 246 / 0.8)'
|
||
: n.type === 'trigger'
|
||
? 'rgb(245 158 11 / 0.8)'
|
||
: 'rgb(100 116 139 / 0.7)'}
|
||
maskColor="rgb(100 116 139 / 0.12)"
|
||
maskStrokeColor="rgb(59 130 246 / 0.5)"
|
||
maskStrokeWidth={4}
|
||
/>
|
||
{/if}
|
||
</SvelteFlow>
|
||
</div>
|
||
|
||
<style lang="postcss">
|
||
:global(.svelte-flow__handle) {
|
||
opacity: 0;
|
||
}
|
||
:global(.svelte-flow__controls-button) {
|
||
@apply bg-surface border-0;
|
||
}
|
||
:global(.svelte-flow__controls-button:hover) {
|
||
@apply bg-surface-hover;
|
||
}
|
||
:global(.svelte-flow__node.selected .drop-shadow-sm) {
|
||
@apply outline outline-2 outline-blue-500;
|
||
}
|
||
/* Activity-panel emphasis — soft, monochromatic, less prominent than the
|
||
blue details selection above. Hover is a thin neutral ring (transient);
|
||
pinning an expanded run is a soft-blue ring. */
|
||
/* A dbt project node and the models it materializes, related by badge
|
||
rather than by edges — hovering either lights up the whole set. */
|
||
:global(.svelte-flow__node.wm-dbt-linked .drop-shadow-sm) {
|
||
@apply outline outline-2 outline-orange-400/80;
|
||
}
|
||
:global(.svelte-flow__node.wm-run-hover .drop-shadow-sm) {
|
||
@apply outline outline-1 outline-gray-400 dark:outline-gray-500;
|
||
}
|
||
:global(.svelte-flow__node.wm-run-selected .drop-shadow-sm) {
|
||
@apply outline outline-2 outline-blue-400/70;
|
||
}
|
||
/* Assets adjacent to an emphasized run — border hue matches the edge:
|
||
blue for a write (output), gray for a read / asset-trigger (input). */
|
||
:global(.svelte-flow__node.wm-asset-output .drop-shadow-sm) {
|
||
@apply outline outline-2 outline-blue-400/70;
|
||
}
|
||
:global(.svelte-flow__node.wm-asset-input .drop-shadow-sm) {
|
||
@apply outline outline-2 outline-gray-400;
|
||
}
|
||
/* Bounded-run pick mode. The start is a solid blue ring; chosen end
|
||
bounds get a thicker amber ring; nodes inside the path-between set ring
|
||
blue; everything out of reach fades back so the matched subset reads at
|
||
a glance. */
|
||
:global(.svelte-flow__node.wm-bound-start .drop-shadow-sm) {
|
||
@apply outline outline-2 outline-blue-500;
|
||
}
|
||
:global(.svelte-flow__node.wm-bound-in .drop-shadow-sm) {
|
||
@apply outline outline-2 outline-blue-400/70;
|
||
}
|
||
:global(.svelte-flow__node.wm-bound-end .drop-shadow-sm) {
|
||
@apply outline outline-[3px] outline-amber-500;
|
||
}
|
||
:global(.svelte-flow__node.wm-bound-dim) {
|
||
@apply opacity-30;
|
||
}
|
||
</style>
|