feat: one card for pipeline nodes, and the pipeline pages in the page header

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 11:30:00 +02:00
co-authored by Claude Opus 5.5
parent 2b2cdfede5
commit 489b6603af
8 changed files with 531 additions and 508 deletions
@@ -529,6 +529,7 @@
runnable_kind: r.usage_kind,
path: r.path,
summary: r.summary,
language: r.language,
in_pipeline: r.in_pipeline ?? false,
partition_kind: r.partition_kind,
freshness: r.freshness,
@@ -1126,30 +1127,30 @@
// the "happening now" signal.
switch (e.kind) {
case 'lineage-write':
style = 'stroke: rgb(59 130 246); stroke-width: 2px;'
style = 'stroke: rgb(59 130 246); stroke-width: 1.25px;'
animated = flowAnimated
markerColor = 'rgb(59 130 246)'
break
case 'lineage-read':
style = 'stroke: rgb(156 163 175); stroke-width: 1.25px;'
style = 'stroke: rgb(156 163 175); stroke-width: 1px;'
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;'
style = 'stroke: rgb(156 163 175); stroke-width: 1px;'
strokeDasharray = '4 3'
markerColor = 'rgb(156 163 175)'
break
case 'trigger-asset':
style = 'stroke: rgb(107 114 128); stroke-width: 2px;'
style = 'stroke: rgb(107 114 128); stroke-width: 1.25px;'
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;'
style = 'stroke: rgb(107 114 128); stroke-width: 1.25px;'
strokeDasharray = '6 3'
markerColor = 'rgb(107 114 128)'
label = 'triggers'
@@ -1159,7 +1160,7 @@
// 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;'
style = 'stroke: rgb(139 92 246); stroke-width: 1px;'
strokeDasharray = '5 3'
markerColor = 'rgb(139 92 246)'
label = e.via_use ? 'uses lib' : 'macros'
@@ -1169,7 +1170,7 @@
// 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;'
style = 'stroke: rgb(217 119 6); stroke-width: 1px;'
strokeDasharray = '5 3'
markerColor = 'rgb(217 119 6)'
label = 'test needs'
@@ -1179,7 +1180,7 @@
// 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;'
style = 'stroke: rgb(234 88 12); stroke-width: 1px;'
strokeDasharray = '4 3'
markerColor = 'rgb(234 88 12)'
label = 'ref'
@@ -1216,7 +1217,7 @@
// (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;'
style = 'stroke: rgb(239 68 68); stroke-width: 1.25px;'
strokeDasharray = '3 3'
markerColor = 'rgb(239 68 68)'
label = 'missing trigger'
@@ -1254,8 +1255,8 @@
style,
markerEnd: {
type: MarkerType.ArrowClosed,
width: 14,
height: 14,
width: 12,
height: 12,
color: markerColor
}
}
@@ -1397,11 +1398,8 @@
: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);
node's own selected state. 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. */
@@ -3,8 +3,7 @@
import { twMerge } from 'tailwind-merge'
import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte'
import { formatAssetKind, formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib'
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
import PipelineNodeCard from './PipelineNodeCard.svelte'
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
import {
ArrowUpRight,
@@ -241,124 +240,121 @@
{/if}
<!-- Mirrors the flow editor's asset pill: quiet surface + gray border at
rest, accent reserved for the selected state. -->
<div
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden border',
'bg-surface-secondary border-gray-400 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-500 transition-colors',
selected && 'bg-surface-accent-selected border-border-selected'
)}
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={data.path}
<PipelineNodeCard
kindLabel={formatAssetKind(asset)}
title={formatShortAssetPath(asset)}
tooltip={data.path}
{selected}
>
<!-- Data identity carries the accent (luminance blue), pairing with
the blue write edges that produce these assets — scripts stay
neutral, so script vs data reads at a glance. -->
<AssetGenericIcon
assetKind={data.asset_kind}
class={`shrink-0 ml-2 mr-2 ${selected ? 'text-accent' : 'text-blue-600 dark:text-blue-400'}`}
size="14px"
/>
{#if data.runStatus}
<!-- The run in view, per relation: a spinner while its producer is
{#snippet icon()}
<!-- Data identity carries the accent (luminance blue), pairing with
the blue write edges that produce these assets — scripts stay
neutral, so script vs data reads at a glance. -->
<AssetGenericIcon
assetKind={data.asset_kind}
class={selected ? 'text-accent' : 'text-blue-600 dark:text-blue-400'}
size="14px"
/>
{/snippet}
{#snippet leading()}
{#if data.runStatus}
<!-- The run in view, per relation: a spinner while its producer is
building it, then its outcome. Left of the name so the eye finds
the moving nodes first on a wide graph. -->
<span
class="shrink-0 mr-1 {data.runStatus === 'failed'
? 'text-red-600 dark:text-red-400'
: data.runStatus === 'materialized'
? 'text-green-600 dark:text-green-400'
: 'text-blue-600 dark:text-blue-400'}"
title={data.runStatus}
>
{#if data.runStatus === 'running'}
<Loader2 size={11} class="animate-spin" />
{:else if data.runStatus === 'failed'}
<XCircle size={11} />
{:else}
<CheckCircle2 size={11} />
{/if}
</span>
<!-- Rows the run wrote. Recorded per relation already, and the cheapest
<span
class="shrink-0 mr-1 {data.runStatus === 'failed'
? 'text-red-600 dark:text-red-400'
: data.runStatus === 'materialized'
? 'text-green-600 dark:text-green-400'
: 'text-blue-600 dark:text-blue-400'}"
title={data.runStatus}
>
{#if data.runStatus === 'running'}
<Loader2 size={11} class="animate-spin" />
{:else if data.runStatus === 'failed'}
<XCircle size={11} />
{:else}
<CheckCircle2 size={11} />
{/if}
</span>
<!-- Rows the run wrote. Recorded per relation already, and the cheapest
answer to "did this model actually produce anything" — a model that
built green but emitted 0 rows is the failure that looks like a
success. Only once settled: mid-build the number is not yet real. -->
{#if data.runStatus === 'materialized' && data.runRowCount != undefined}
<span
class="shrink-0 mr-1 text-3xs tabular-nums text-tertiary"
title="{data.runRowCount} rows written by this run"
>
{Intl.NumberFormat().format(data.runRowCount)}
</span>
{#if data.runStatus === 'materialized' && data.runRowCount != undefined}
<span
class="shrink-0 mr-1 text-3xs tabular-nums text-tertiary"
title="{data.runRowCount} rows written by this run"
>
{Intl.NumberFormat().format(data.runRowCount)}
</span>
{/if}
{/if}
{/if}
<div class="flex flex-col min-w-0 flex-1 pr-1 py-1 leading-tight">
<span class="text-3xs uppercase tracking-wide truncate text-tertiary">
{formatAssetKind(asset)}
</span>
<span class="text-2xs font-mono text-emphasis truncate">{formatShortAssetPath(asset)}</span>
</div>
<!-- Fork data-environment chip: in a fork every asset shares its parent's
{/snippet}
{#snippet trailing()}
<!-- Fork data-environment chip: in a fork every asset shares its parent's
name, so the env it resolves to must read at a glance. Labeled + tinted
(amber "parent" = deferred read of the parent's current table via a
view; emerald "fork" = the fork's own materialized copy) rather than a
bare icon, which was too easy to miss. The title carries the detail. -->
{#if data.fork_materialization === 'deferred'}
<span
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700"
title="Deferred to parent workspace: reads the parent's current data. Materialize it in this fork to iterate on it."
>
<ArrowUpRight size={10} />
parent
</span>
{:else if data.fork_materialization === 'fork'}
<span
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-700"
title="Materialized in this fork: reads and writes use the fork's isolated copy."
>
<GitFork size={10} />
fork
</span>
{/if}
<!-- dbt chip: names the materialization dbt declares, plus a test count
{#if data.fork_materialization === 'deferred'}
<span
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700"
title="Deferred to parent workspace: reads the parent's current data. Materialize it in this fork to iterate on it."
>
<ArrowUpRight size={10} />
parent
</span>
{:else if data.fork_materialization === 'fork'}
<span
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-700"
title="Materialized in this fork: reads and writes use the fork's isolated copy."
>
<GitFork size={10} />
fork
</span>
{/if}
<!-- dbt chip: names the materialization dbt declares, plus a test count
when the model carries generic tests. Orange keeps it visually
separate from the fork/SCD2 chips, which describe Windmill state.
Where the project node is on the graph it also stands in for the edge
to it: hovering lights that node up, clicking selects it. Where it is
not — the run page, and a pipeline holding a dbt project — there is
nothing to point at, so the chip renders inert. -->
{#if data.dbt}
<button
type="button"
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold tracking-wide bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 border border-orange-300 dark:border-orange-700 {data.onDbtSelect
? 'hover:brightness-95 cursor-pointer'
: 'cursor-default'}"
title={dbtTitle}
onmouseenter={() => data.onDbtHover?.(true)}
onmouseleave={() => data.onDbtHover?.(false)}
onclick={(e) => {
e.stopPropagation()
data.onDbtSelect?.()
}}
>
<DbtIcon width={9} height={9} />
{dbtLabel}
{#if data.dbt.data_tests?.length}
<span class="opacity-70">&middot; {data.dbt.data_tests.length}T</span>
{/if}
</button>
{/if}
<!-- SCD2 companion marker: this node is the `<dim>_current` "latest row
{#if data.dbt}
<button
type="button"
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold tracking-wide bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 border border-orange-300 dark:border-orange-700 {data.onDbtSelect
? 'hover:brightness-95 cursor-pointer'
: 'cursor-default'}"
title={dbtTitle}
onmouseenter={() => data.onDbtHover?.(true)}
onmouseleave={() => data.onDbtHover?.(false)}
onclick={(e) => {
e.stopPropagation()
data.onDbtSelect?.()
}}
>
<DbtIcon width={9} height={9} />
{dbtLabel}
{#if data.dbt.data_tests?.length}
<span class="opacity-70">&middot; {data.dbt.data_tests.length}T</span>
{/if}
</button>
{/if}
<!-- SCD2 companion marker: this node is the `<dim>_current` "latest row
per key" view its producer maintains alongside the base dimension.
Icon-only (the pill already truncates); the title names the base. -->
{#if data.derived_from}
<span
class="shrink-0 mr-1.5 text-violet-600 dark:text-violet-400"
title={`SCD2 current view of ${data.derived_from}: latest row per key, maintained by the same producer as the base dimension.`}
>
<History size={12} />
</span>
{/if}
</div>
{#if data.derived_from}
<span
class="shrink-0 mr-1.5 text-violet-600 dark:text-violet-400"
title={`SCD2 current view of ${data.derived_from}: latest row per key, maintained by the same producer as the base dimension.`}
>
<History size={12} />
</span>
{/if}
{/snippet}
</PipelineNodeCard>
{#if showGuardBadge}
<!-- Data-test outcome badge. Floats off the TOP-RIGHT corner (opposite the
left-edge run button and the bottom + inserter) so it never collides
@@ -406,25 +402,29 @@
{/if}
{#if showAdd}
<!-- Always-visible + below the asset for downstream pipeline-script
creation. Half-overlapping the bottom edge so it visually attaches
to the node like the flow editor's between-step inserter. -->
<div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10">
<PipelineInsertMenu
kinds={[
{
id: 'pipeline_script',
label: 'Add downstream pipeline script',
description: 'Triggered when this asset changes',
icon: Code2
}
]}
languages={PIPELINE_LANGUAGES as any}
pathPrefix={data.pathPrefix ?? ''}
defaultPathSuffix={data.defaultPathSuffix ?? ''}
onPick={handlePick}
>
{#snippet trigger()}
<!--
creation, hung under the node by a short connector so it clears the
node's text and reads as attached to it. -->
<div
class="absolute left-1/2 top-full -translate-x-1/2 z-10 flex flex-col items-center pointer-events-none"
>
<div class="w-px h-3 bg-gray-300 dark:bg-gray-600"></div>
<div class="pointer-events-auto">
<PipelineInsertMenu
kinds={[
{
id: 'pipeline_script',
label: 'Add downstream pipeline script',
description: 'Triggered when this asset changes',
icon: Code2
}
]}
languages={PIPELINE_LANGUAGES as any}
pathPrefix={data.pathPrefix ?? ''}
defaultPathSuffix={data.defaultPathSuffix ?? ''}
onPick={handlePick}
>
{#snippet trigger()}
<!--
Sizing notes for the round + button:
- w-6/h-6 (24px) chosen so that with border-2 (2px each
side) the inner area is exactly 20px — divisible by
@@ -441,16 +441,17 @@
text rendering, otherwise the icon is shifted
downward by ~0.5px at fractional zooms.
-->
<button
type="button"
onclick={(e) => e.stopPropagation()}
class="bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:bg-surface-hover rounded-full w-6 h-6 grid place-items-center shadow-sm leading-none"
title="Add downstream pipeline script"
>
<Plus size={16} strokeWidth={2.5} />
</button>
{/snippet}
</PipelineInsertMenu>
<button
type="button"
onclick={(e) => e.stopPropagation()}
class="bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:bg-surface-hover rounded-full w-6 h-6 grid place-items-center shadow-sm leading-none"
title="Add downstream pipeline script"
>
<Plus size={16} strokeWidth={2.5} />
</button>
{/snippet}
</PipelineInsertMenu>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,103 @@
<!--
@component
The body every pipeline graph node shares: icon, a kind line ("Script · draft", "Data table"),
a title, and trailing chips. Node components add only what is theirs — the icon, the chips,
and the controls hung around the card.
-->
<script lang="ts">
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
import { twMerge } from 'tailwind-merge'
import type { Snippet } from 'svelte'
interface Props {
icon: Snippet
kindLabel: string
title: string
/** Hover text; the path, when the title is a summary. */
tooltip?: string
/** Scripts sit on the main surface; assets and triggers a step down. */
surface?: 'primary' | 'secondary'
selected?: boolean
/** Not deployed yet: dashed edge. */
draft?: boolean
/** Feedback states, which recolor the whole card. */
tone?: 'danger' | 'success' | 'running'
/** Makes the whole card a button. */
onclick?: (e: MouseEvent) => void
/** Between the icon and the text: a live status the eye should find first. */
leading?: Snippet
trailing?: Snippet
}
let {
icon,
kindLabel,
title,
tooltip,
surface = 'secondary',
selected = false,
draft = false,
tone,
onclick,
leading,
trailing
}: Props = $props()
const cardClass = $derived(
twMerge(
'flex items-center w-full text-left font-normal rounded-md drop-shadow-sm overflow-hidden border transition-colors',
surface === 'primary' ? 'bg-surface' : 'bg-surface-secondary',
'border-gray-400 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-500',
draft && 'border-dashed border-gray-400 dark:border-gray-500',
// Like a selected flow step: the fill and the edge colour change, the edge's
// width and dashes stay.
selected && 'bg-surface-accent-selected border-border-selected hover:border-border-selected',
tone === 'danger' &&
'bg-red-50 dark:bg-red-900/30 border-dashed border-red-400 dark:border-red-500 hover:bg-red-100 dark:hover:bg-red-900/40',
tone === 'success' &&
'bg-green-50 dark:bg-green-900/30 border-green-500 dark:border-green-600',
tone === 'running' &&
'bg-amber-50 dark:bg-amber-900/30 border-amber-400 dark:border-amber-600 animate-pulse'
)
)
const toneText = $derived(
tone === 'danger'
? 'text-red-700 dark:text-red-400'
: tone === 'success'
? 'text-green-700 dark:text-green-400'
: undefined
)
</script>
{#snippet body()}
<span class="shrink-0 ml-3 mr-2.5 flex items-center">{@render icon()}</span>
{@render leading?.()}
<span class="flex flex-col min-w-0 flex-1 pr-1 py-1 leading-tight">
<span class={twMerge('text-2xs truncate text-secondary', toneText)}>{kindLabel}</span>
<span class={twMerge('text-xs truncate text-emphasis', selected && 'text-accent', toneText)}
>{title}</span
>
</span>
{@render trailing?.()}
{/snippet}
{#if onclick}
<button
type="button"
{onclick}
class={cardClass}
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={tooltip}
>
{@render body()}
</button>
{:else}
<div
class={cardClass}
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={tooltip}
>
{@render body()}
</div>
{/if}
@@ -24,8 +24,9 @@
import type { GraphUsageKind } from './types'
import type { RunnableRunState } from './activeRunnables.svelte'
import { parseDurationSecs } from './parsePipelineAnnotations'
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import PipelineNodeCard from './PipelineNodeCard.svelte'
import type { ScriptLang } from '$lib/gen'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import type { Item } from '$lib/utils'
@@ -39,6 +40,7 @@
runnable_kind: GraphUsageKind
path: string
summary?: string
language?: ScriptLang
in_pipeline?: boolean
partition_kind?: 'daily' | 'hourly' | 'weekly' | 'monthly' | 'dynamic'
freshness?: string
@@ -217,134 +219,132 @@
<!--
Mirrors the flow editor's step styling (getNodeColorClasses): muted
surface-tertiary fill with a quiet gray border, accent only for the
selected state, dashes for unsaved drafts. The 2px dashed stroke is
necessary for the dash gaps to be readable; 1px disappears into the
fill.
selected state, dashes for unsaved drafts — 1px, like the trigger
nodes' dashed outline, so every draft node reads the same.
-->
<div
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden border transition-colors',
'bg-surface border-gray-400 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-500',
selected && 'bg-surface-accent-selected border-border-selected',
data.unsaved && 'border-2 border-dashed border-gray-400 dark:border-gray-500',
computingNow &&
'bg-amber-50 dark:bg-amber-900/30 border-amber-400 dark:border-amber-600 animate-pulse'
)}
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={nodeTooltip}
<PipelineNodeCard
surface="primary"
kindLabel="{kindLabel}{data.unsaved ? ' · draft' : ''}"
title={data.summary || data.path}
tooltip={nodeTooltip}
{selected}
draft={data.unsaved}
tone={computingNow ? 'running' : undefined}
>
<Icon size={14} class={`shrink-0 ml-2 mr-2 ${selected ? 'text-accent' : 'text-secondary'}`} />
<div class="flex flex-col min-w-0 flex-1 pr-1 py-1 leading-tight">
<span class="text-3xs uppercase tracking-wide truncate text-tertiary">
{kindLabel}{data.unsaved ? ' · draft' : ''}
</span>
<span class="text-xs text-emphasis truncate">{data.summary || data.path}</span>
</div>
<!-- Annotation chips share one neutral treatment — the icon carries
{#snippet icon()}
{#if data.language && data.runnable_kind === 'script'}
<LanguageIcon lang={data.language} width={14} height={14} />
{:else}
<Icon size={14} class={selected ? 'text-accent' : 'text-secondary'} />
{/if}
{/snippet}
{#snippet trailing()}
<!-- Annotation chips share one neutral treatment — the icon carries
the meaning (colors are reserved for feedback, per the brand
guidelines). Only the freshness chip (when it has a verdict)
and the run-state chip below use semantic colors. -->
{#if data.partition_kind}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// partitioned ${data.partition_kind}`}
>
<Layers size={10} />
<span class="text-3xs leading-none">{data.partition_kind}</span>
</div>
{/if}
<!-- Freshness is the one annotation chip that carries feedback (a
{#if data.partition_kind}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// partitioned ${data.partition_kind}`}
>
<Layers size={10} />
<span class="text-3xs leading-none">{data.partition_kind}</span>
</div>
{/if}
<!-- Freshness is the one annotation chip that carries feedback (a
fresh/stale verdict against real run history), so like the
run-state chip it uses semantic colors: emerald = within window,
amber = stale. Neutral when there's no verdict (drafts, bad
window value). -->
{#if data.freshness}
<div
class={twMerge(
'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm',
freshnessState === 'fresh'
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
: freshnessState === 'stale'
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
: 'bg-surface-secondary text-secondary'
)}
title={freshnessTooltip}
>
<Timer size={10} />
<span class="text-3xs leading-none">{data.freshness}</span>
</div>
{/if}
{#if data.tag}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// tag ${data.tag}`}
>
<Tag size={10} />
<span class="text-3xs leading-none">{data.tag}</span>
</div>
{/if}
{#if data.retry}
{@const r = data.retry}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// retry ${r.count}${r.delay ? ` ${r.delay}` : ''}`}
>
<RotateCw size={10} />
<span class="text-3xs leading-none">×{r.count}</span>
</div>
{/if}
{#if data.macros && data.macros.length > 0}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// macros — defines ${data.macros.length} macro${data.macros.length > 1 ? 's' : ''}:\n${data.macros
.map((m) => `• ${m.name}(${m.params})${m.is_table ? ' → table' : ''}`)
.join('\n')}`}
>
<SquareFunction size={10} />
<span class="text-3xs leading-none">×{data.macros.length}</span>
</div>
{/if}
{#if data.dbt}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300"
title={`dbt project — materializes ${data.dbt.model_count} model${data.dbt.model_count === 1 ? '' : 's'}`}
>
<DbtIcon width={10} height={10} />
<span class="text-3xs leading-none">×{data.dbt.model_count}</span>
</div>
{/if}
{#if data.runState}
{@const rs = data.runState}
<div
class={twMerge(
'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm',
rs.status === 'running'
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'
: rs.status === 'success'
{#if data.freshness}
<div
class={twMerge(
'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm',
freshnessState === 'fresh'
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
: 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300'
)}
title={`${
rs.status === 'running'
? 'Running now'
: rs.status === 'success'
? 'Last run succeeded'
: 'Last run failed'
}${rs.runs > 0 ? ` — ran ${rs.runs}× this session` : ''}`}
>
{#if rs.status === 'running'}
<Loader2 size={10} class="animate-spin" />
{:else if rs.status === 'success'}
<CheckCircle2 size={10} />
{:else}
<XCircle size={10} />
{/if}
{#if rs.runs > 0}
<span class="text-3xs leading-none tabular-nums">×{rs.runs}</span>
{/if}
</div>
{/if}
</div>
: freshnessState === 'stale'
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
: 'bg-surface-secondary text-secondary'
)}
title={freshnessTooltip}
>
<Timer size={10} />
<span class="text-3xs leading-none">{data.freshness}</span>
</div>
{/if}
{#if data.tag}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// tag ${data.tag}`}
>
<Tag size={10} />
<span class="text-3xs leading-none">{data.tag}</span>
</div>
{/if}
{#if data.retry}
{@const r = data.retry}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// retry ${r.count}${r.delay ? ` ${r.delay}` : ''}`}
>
<RotateCw size={10} />
<span class="text-3xs leading-none">×{r.count}</span>
</div>
{/if}
{#if data.macros && data.macros.length > 0}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// macros — defines ${data.macros.length} macro${data.macros.length > 1 ? 's' : ''}:\n${data.macros
.map((m) => `• ${m.name}(${m.params})${m.is_table ? ' → table' : ''}`)
.join('\n')}`}
>
<SquareFunction size={10} />
<span class="text-3xs leading-none">×{data.macros.length}</span>
</div>
{/if}
{#if data.dbt}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300"
title={`dbt project — materializes ${data.dbt.model_count} model${data.dbt.model_count === 1 ? '' : 's'}`}
>
<DbtIcon width={10} height={10} />
<span class="text-3xs leading-none">×{data.dbt.model_count}</span>
</div>
{/if}
{#if data.runState}
{@const rs = data.runState}
<div
class={twMerge(
'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm',
rs.status === 'running'
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'
: rs.status === 'success'
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
: 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300'
)}
title={`${
rs.status === 'running'
? 'Running now'
: rs.status === 'success'
? 'Last run succeeded'
: 'Last run failed'
}${rs.runs > 0 ? ` — ran ${rs.runs}× this session` : ''}`}
>
{#if rs.status === 'running'}
<Loader2 size={10} class="animate-spin" />
{:else if rs.status === 'success'}
<CheckCircle2 size={10} />
{:else}
<XCircle size={10} />
{/if}
{#if rs.runs > 0}
<span class="text-3xs leading-none tabular-nums">×{rs.runs}</span>
{/if}
</div>
{/if}
{/snippet}
</PipelineNodeCard>
{#if showRun}
<!-- Run button revealed on hover/select. Matches the placement, size,
and behaviour of AssetNode's run button so both nodes feel
@@ -43,24 +43,23 @@
}
export const TRIGGER_NODE_STYLE: Record<TriggerNodeKind, Presentation> = {
schedule: { icon: Clock, label: 'schedule', ...MUTED },
webhook: { icon: Webhook, label: 'webhook', ...MUTED },
email: { icon: Mail, label: 'email', ...MUTED },
kafka: { icon: Zap, label: 'kafka', ...MUTED },
mqtt: { icon: Radio, label: 'mqtt', ...MUTED },
amqp: { icon: Radio, label: 'amqp', ...MUTED },
nats: { icon: MessageSquare, label: 'nats', ...MUTED },
postgres: { icon: Database, label: 'postgres', ...MUTED },
sqs: { icon: Send, label: 'sqs', ...MUTED },
gcp: { icon: CloudCog, label: 'gcp', ...MUTED },
data_upload: { icon: Upload, label: 'data upload', ...MUTED }
schedule: { icon: Clock, label: 'Schedule', ...MUTED },
webhook: { icon: Webhook, label: 'Webhook', ...MUTED },
email: { icon: Mail, label: 'Email', ...MUTED },
kafka: { icon: Zap, label: 'Kafka', ...MUTED },
mqtt: { icon: Radio, label: 'MQTT', ...MUTED },
amqp: { icon: Radio, label: 'AMQP', ...MUTED },
nats: { icon: MessageSquare, label: 'NATS', ...MUTED },
postgres: { icon: Database, label: 'Postgres', ...MUTED },
sqs: { icon: Send, label: 'SQS', ...MUTED },
gcp: { icon: CloudCog, label: 'GCP Pub/Sub', ...MUTED },
data_upload: { icon: Upload, label: 'Data upload', ...MUTED }
}
</script>
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
import PipelineNodeCard from './PipelineNodeCard.svelte'
import { twMerge } from 'tailwind-merge'
import { AlertTriangle, CheckCircle2, EllipsisVertical, Target, Trash2 } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
@@ -148,7 +147,7 @@
let Icon = $derived(displayMissing ? AlertTriangle : style.icon)
let missingTitle = $derived(
displayMissing
? `Missing ${style.label} trigger: ${data.runnable_path ?? ''} declares \`// on ${style.label}\` but no ${style.label} trigger targets it. Click to create one, or remove the annotation.`
? `Missing ${style.label} trigger: ${data.runnable_path ?? ''} declares \`// on ${data.kind}\` but no ${style.label} trigger targets it. Click to create one, or remove the annotation.`
: undefined
)
// Webhook gets a drawer (URLs + webhook-specific token creation) rather
@@ -217,6 +216,67 @@
: [])
])
// One card for every state: which label, title and click the node gets.
let card = $derived.by(
(): {
kindLabel: string
title: string
tooltip?: string
draft: boolean
tone?: 'danger' | 'success'
onclick?: () => void
} => {
const unsaved = !!data.unsaved
if (canCreate)
return {
kindLabel: `${style.label} · missing`,
title: 'Click to create',
tooltip: missingTitle,
draft: false,
tone: 'danger',
onclick: handleMissingClick
}
if (canEdit)
return {
kindLabel: `${style.label}${data.draft ? ' · draft' : unsaved ? ' · unsaved' : ''}`,
title: data.ref,
tooltip: data.draft
? `Draft ${style.label}: ${data.ref} — created when you save the pipeline. Click to edit.`
: `Edit ${style.label} trigger: ${data.ref}`,
draft: unsaved,
onclick: handleEditClick
}
if (canOpenWebhook)
return {
kindLabel: style.label,
title: 'URLs & token',
tooltip: `Webhook endpoint for ${data.runnable_path ?? ''} — click to view URLs and create a token`,
draft: unsaved,
onclick: handleWebhookClick
}
if (canOpenDataUpload)
return {
kindLabel: `${style.label}${dataUploadReady ? ' · ready' : ''}`,
title: dataUploadReady ? 'File staged' : 'Upload & run',
tooltip: dataUploadReady
? `Data upload for ${data.runnable_path ?? ''} — a file is staged; the pipeline is ready to run. Click to change it.`
: `Data upload for ${data.runnable_path ?? ''} — click to open the run form and upload a file`,
draft: !dataUploadReady && unsaved,
tone: dataUploadReady ? 'success' : undefined,
onclick: handleDataUploadClick
}
return {
kindLabel: `${style.label}${displayMissing ? ' · missing' : unsaved ? ' · unsaved' : ''}`,
title: displayMissing ? 'no trigger row' : data.ref,
tooltip:
missingTitle ??
(unsaved ? `Unsaved ${style.label}: ${data.ref}` : `${style.label}: ${data.ref}`),
draft: unsaved && !displayMissing,
tone: displayMissing ? 'danger' : undefined
}
}
)
function handleMissingClick() {
if (!canCreate || !data.runnable_path || !data.onCreateMissingTrigger) return
data.onCreateMissingTrigger(data.kind as NativeTriggerKind, data.runnable_path)
@@ -240,167 +300,28 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" onmouseenter={() => (hover = true)} onmouseleave={() => (hover = false)}>
{#if canCreate}
<!-- Same affordance as the asset's downstream + button: render the
whole node as a button so the cursor + hover + click is obvious
without depending on svelte-flow's wrapper-level handlers. -->
<button
type="button"
onclick={handleMissingClick}
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1 w-full text-left',
'bg-red-50 dark:bg-red-900/30 outline-dashed outline-red-400 dark:outline-red-500',
'hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors'
)}
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={missingTitle}
>
<Icon size={14} class="shrink-0 ml-2 mr-2 text-red-600 dark:text-red-400" />
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span class="text-3xs uppercase tracking-wide truncate text-red-700 dark:text-red-400">
{style.label} · missing
</span>
<span class="text-2xs font-mono truncate text-red-700 dark:text-red-400">
Click to create
</span>
</div>
</button>
{:else if canEdit}
<!-- Mirrors the missing-trigger pattern: render the whole node as a
button so clicks open the editor drawer reliably (don't rely on
svelte-flow's onnodeclick wiring). -->
<button
type="button"
onclick={handleEditClick}
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1 w-full text-left',
style.bg,
data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border,
'hover:brightness-95 dark:hover:brightness-110 transition-[filter]'
)}
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={data.draft
? `Draft ${style.label}: ${data.ref} — created when you save the pipeline. Click to edit.`
: `Edit ${style.label} trigger: ${data.ref}`}
>
<Icon size={14} class={`shrink-0 ml-2 mr-2 ${style.iconText}`} />
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span class="text-3xs uppercase tracking-wide truncate text-tertiary">
{style.label}{data.draft ? ' · draft' : data.unsaved ? ' · unsaved' : ''}
</span>
<span class="text-2xs font-mono truncate text-emphasis">
{data.ref}
</span>
</div>
</button>
{:else if canOpenWebhook}
<!-- Webhook endpoint: implicit (no trigger row), so the node opens a
drawer with the URLs + webhook-specific token creation instead of
an editor. Styled like an attached trigger, never the red
"missing" state. -->
<button
type="button"
onclick={handleWebhookClick}
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1 w-full text-left',
style.bg,
data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border,
'hover:brightness-95 dark:hover:brightness-110 transition-[filter]'
)}
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={`Webhook endpoint for ${data.runnable_path ?? ''} — click to view URLs and create a token`}
>
<Icon size={14} class={`shrink-0 ml-2 mr-2 ${style.iconText}`} />
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span class="text-3xs uppercase tracking-wide truncate text-tertiary">
{style.label}
</span>
<span class="text-2xs font-mono truncate text-emphasis"> URLs & token </span>
</div>
</button>
{:else if canOpenDataUpload}
<!-- Data upload: UI-first entry point (no trigger row). Clicking the
node opens the target script's run form, where the auto-generated
S3 picker lets the user upload a file and run the pipeline. Goes
green once a file is staged (ready), so "Run pipeline" can proceed;
until then it stays neutral with an "upload a file" prompt. Never
the red "missing" state. -->
<button
type="button"
onclick={handleDataUploadClick}
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1 w-full text-left',
dataUploadReady
? 'bg-green-50 dark:bg-green-900/30 outline-green-500 dark:outline-green-600'
: style.bg,
dataUploadReady ? '' : data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border,
'hover:brightness-95 dark:hover:brightness-110 transition-[filter]'
)}
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={dataUploadReady
? `Data upload for ${data.runnable_path ?? ''} — a file is staged; the pipeline is ready to run. Click to change it.`
: `Data upload for ${data.runnable_path ?? ''} — click to open the run form and upload a file`}
>
<DataUploadIcon
size={14}
class={`shrink-0 ml-2 mr-2 ${dataUploadReady ? 'text-green-600 dark:text-green-400' : style.iconText}`}
/>
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span
class={twMerge(
'text-3xs uppercase tracking-wide truncate',
dataUploadReady ? 'text-green-700 dark:text-green-400' : 'text-tertiary'
)}
>
{style.label}{dataUploadReady ? ' · ready' : ''}
</span>
<span
class={twMerge(
'text-2xs font-mono truncate',
dataUploadReady ? 'text-green-700 dark:text-green-400' : 'text-emphasis'
)}
>
{dataUploadReady ? 'File staged' : 'Upload & run'}
</span>
</div>
</button>
{:else}
<div
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1',
displayMissing
? 'bg-red-50 dark:bg-red-900/30 outline-dashed outline-red-400 dark:outline-red-500'
: style.bg,
displayMissing ? '' : data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border
)}
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={missingTitle ??
(data.unsaved ? `Unsaved ${style.label}: ${data.ref}` : `${style.label}: ${data.ref}`)}
>
<Icon
size={14}
class={`shrink-0 ml-2 mr-2 ${displayMissing ? 'text-red-600 dark:text-red-400' : style.iconText}`}
/>
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span
class={twMerge(
'text-3xs uppercase tracking-wide truncate',
displayMissing ? 'text-red-700 dark:text-red-400' : 'text-tertiary'
)}
>
{style.label}{displayMissing ? ' · missing' : data.unsaved ? ' · unsaved' : ''}
</span>
<span
class={twMerge(
'text-2xs font-mono truncate',
displayMissing ? 'text-red-700 dark:text-red-400' : 'text-emphasis'
)}
>
{displayMissing ? 'no trigger row' : data.ref}
</span>
</div>
</div>
{/if}
<PipelineNodeCard
kindLabel={card.kindLabel}
title={card.title}
tooltip={card.tooltip}
draft={card.draft}
tone={card.tone}
onclick={card.onclick}
>
{#snippet icon()}
{#if canOpenDataUpload}
<DataUploadIcon
size={14}
class={dataUploadReady ? 'text-green-600 dark:text-green-400' : style.iconText}
/>
{:else}
<Icon
size={14}
class={displayMissing || canCreate ? 'text-red-600 dark:text-red-400' : style.iconText}
/>
{/if}
{/snippet}
</PipelineNodeCard>
{#if menuItems.length > 0}
<!-- Hover-revealed kebab menu (Delete only for now). Mirrors the
@@ -70,9 +70,10 @@ export interface DbtDataTest {
export interface AssetGraphRunnableNode {
path: string
usage_kind: GraphUsageKind
// The script's summary, filled in client-side (not sent by the backend);
// the canvas titles the node with it over the path.
// Filled in client-side (not sent by the backend): the canvas titles the
// node with the summary over the path, and shows the language's icon.
summary?: string
language?: import('$lib/gen').ScriptLang
// Script has `// pipeline` annotation. Drives the pipeline-member
// visual state; unrelated to what the script actually writes (that's
// parsed separately into lineage edges).
@@ -3,7 +3,8 @@
import PipelineFolderList from '$lib/components/assets/AssetGraph/PipelineFolderList.svelte'
import PipelineSetupSignpost from '$lib/components/assets/AssetGraph/PipelineSetupSignpost.svelte'
import PipelineAlphaAckModal from '$lib/components/assets/AssetGraph/PipelineAlphaAckModal.svelte'
import { BookOpen, NetworkIcon } from 'lucide-svelte'
import { BookOpen } from 'lucide-svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { onMount } from 'svelte'
@@ -36,16 +37,13 @@
<title>Pipelines — Windmill</title>
</svelte:head>
<PageHeaderContent section={{ label: 'Pipelines' }} afterName={alphaBadge} />
{#snippet alphaBadge()}
<Badge color="green">Alpha</Badge>
{/snippet}
<div class="flex flex-col h-full">
<div
class="border-b flex flex-row justify-between gap-2 px-2 py-1 items-center min-h-10 shrink-0 whitespace-nowrap"
>
<div class="flex flex-row items-center gap-2">
<NetworkIcon size={16} class="text-secondary shrink-0" />
<h1 class="text-sm font-semibold text-emphasis">Pipelines</h1>
<Badge color="green">Alpha</Badge>
</div>
</div>
<div class="flex-1 min-h-0 overflow-y-auto bg-surface">
<div class="max-w-2xl mx-auto flex flex-col gap-6 px-4 py-8">
@@ -18,6 +18,7 @@
import { usePipelineHistory } from '$lib/components/assets/AssetGraph/pipelineHistory.svelte'
import PipelineActivityPanel from '$lib/components/assets/AssetGraph/PipelineActivityPanel.svelte'
import PipelinePickerModal from '$lib/components/assets/AssetGraph/PipelinePickerModal.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import {
extractWrites,
extractReads,
@@ -102,7 +103,6 @@
import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
import {
AlertTriangle,
ArrowLeft,
ChevronDown,
Circle,
Download,
@@ -300,8 +300,11 @@
// appear in the current `g.runnables`. That self-cleaning property is
// the whole reason for the refactor — no rename/delete cleanup needed.
let bodiesByPath = $state<Map<string, string>>(new Map())
// Deployed summaries from the same fetch, for node titles. Same only-add cache.
let summariesByPath = $state<Map<string, string>>(new Map())
// Deployed summary + language from the same fetch, for node titles and icons.
// Same only-add cache.
let scriptMetaByPath = $state<Map<string, { summary?: string; language: ScriptLang }>>(
new Map()
)
// Sibling cache: the parsed asset usages from `inferAssets` (wasm), one
// pass per body. Same only-add semantics as `bodiesByPath`.
let inferredAssetsByPath = $state<Map<string, AssetWithAltAccessType[]>>(new Map())
@@ -1330,10 +1333,11 @@
return {
...g,
runnables: g.runnables.map((r) => {
const summary =
(showDrafts ? pe.drafts.get(r.path)?.script.summary : undefined) ||
summariesByPath.get(r.path)
return summary ? { ...r, summary } : r
const draft = showDrafts ? pe.drafts.get(r.path)?.script : undefined
const meta = scriptMetaByPath.get(r.path)
const summary = draft?.summary || meta?.summary
const language = draft?.language ?? meta?.language
return summary || language ? { ...r, summary, language } : r
})
}
}
@@ -2447,8 +2451,11 @@
nextBodies.set(path, content)
bodiesByPath = nextBodies
}
if (s.summary && !summariesByPath.has(path)) {
summariesByPath = new Map(summariesByPath).set(path, s.summary)
if (!scriptMetaByPath.has(path)) {
scriptMetaByPath = new Map(scriptMetaByPath).set(path, {
summary: s.summary || undefined,
language: s.language
})
}
if (!inferredAssetsByPath.has(path)) {
const nextAssets = new Map(inferredAssetsByPath)
@@ -2508,56 +2515,50 @@
<title>Pipeline · {folder} — Windmill</title>
</svelte:head>
<div class="flex flex-col h-full">
<div
class="border-b flex flex-row justify-between gap-2 px-2 py-1 items-center overflow-y-visible overflow-x-auto min-h-12 shrink-0 whitespace-nowrap"
<PageHeaderContent section={{ label: 'Pipelines', content: pipelineCrumb }} actions={pipelineActions} />
{#snippet pipelineCrumb()}
<a
href="{base}/pipeline"
class="flex items-center gap-1 px-1 py-0.5 rounded text-xs text-primary hover:bg-surface-hover hover:text-emphasis transition-colors"
>
<div class="flex flex-row items-center gap-2 flex-1 min-w-0">
<Button
variant="subtle"
unifiedSize="sm"
href="{base}/pipeline"
startIcon={{ icon: ArrowLeft }}
iconOnly
title="Back to pipelines"
/>
<NetworkIcon size={16} class="text-tertiary shrink-0" />
<h1 class="text-sm font-semibold">{mode === 'edit' ? 'Pipeline editor' : 'Pipeline'}</h1>
<span class="text-tertiary text-sm">·</span>
{#if otherPipelineFolders.length === 0}
<button
type="button"
onclick={() => (pickerModalOpen = true)}
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 bg-surface hover:bg-surface-hover transition-colors"
title="Switch pipeline folder"
>
<Folder size={14} class="text-tertiary shrink-0" />
<span class="text-sm font-mono font-medium text-emphasis">f/{folder}</span>
<ChevronDown size={12} class="text-tertiary" />
</button>
{:else}
<DropdownV2 size="sm" items={folderSwitcherItems}>
{#snippet buttonReplacement()}
<span
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 bg-surface hover:bg-surface-hover transition-colors"
title="Switch pipeline folder"
>
<Folder size={14} class="text-tertiary shrink-0" />
<span class="text-sm font-mono font-medium text-emphasis">f/{folder}</span>
<ChevronDown size={12} class="text-tertiary" />
</span>
{/snippet}
</DropdownV2>
{/if}
{#if summary.length > 0}
<span class="text-xs text-tertiary">· {summary.join(' · ')}</span>
{/if}
</div>
<NetworkIcon size={14} class="shrink-0 text-tertiary" />
Pipelines
</a>
<span class="shrink-0 text-hint/40 text-xs" aria-hidden="true">/</span>
{#snippet folderSegment()}
<span
class="flex items-center gap-1 px-1 py-0.5 rounded text-xs text-primary hover:bg-surface-hover hover:text-emphasis transition-colors"
title="Switch pipeline folder"
>
<Folder size={14} class="shrink-0 text-tertiary" />
<span class="truncate font-normal">{folder}</span>
<ChevronDown size={14} class="shrink-0 text-tertiary" />
</span>
{/snippet}
{#if otherPipelineFolders.length === 0}
<button type="button" onclick={() => (pickerModalOpen = true)}>
{@render folderSegment()}
</button>
{:else}
<DropdownV2 size="sm" items={folderSwitcherItems}>
{#snippet buttonReplacement()}
{@render folderSegment()}
{/snippet}
</DropdownV2>
{/if}
{#if summary.length > 0}
<span class="text-2xs text-secondary truncate">{summary.join(' · ')}</span>
{/if}
{/snippet}
{#snippet pipelineActions()}
<div class="flex flex-row items-center gap-2">
{#if !isOperator}
<!-- Center group: the mode toggle is the page's primary control —
anchored between the two flex-1 side groups so it stays
centered, with breathing room on both sides. -->
<div class="flex flex-row items-center gap-2 shrink-0 px-6">
<div class="flex flex-row items-center gap-2 shrink-0">
<PipelineModeToggle {mode} draftCount={pendingCount} onModeChange={(m) => setMode(m)} />
{#if mode === 'view' && pendingCount > 0}
<!-- View variant: overlay the unsaved drafts onto the deployed
@@ -2582,7 +2583,6 @@
{/if}
</div>
{/if}
<div class="flex flex-row items-center gap-2 flex-1 justify-end">
{#if !isOperator && allPipelineScripts.length > 0}
<!-- Only the armed state surfaces on the bar (arming lives in the ⋮
menu) — a compact disarm pill. -->
@@ -2702,9 +2702,10 @@
title="Refresh"
/>
<DropdownV2 size="sm" items={overflowMenuItems} />
</div>
</div>
{/snippet}
<div class="flex flex-col h-full">
<div class="flex-1 min-h-0">
{#if graphRes.loading && !graphRes.current}
<div class="h-full flex items-center justify-center gap-2 text-tertiary">