mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
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:
co-authored by
Claude Opus 5.5
parent
2b2cdfede5
commit
489b6603af
@@ -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">· {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">· {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">
|
||||
|
||||
Reference in New Issue
Block a user