Files
windmill/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte
T

713 lines
28 KiB
Svelte

<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import { twMerge } from 'tailwind-merge'
import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte'
import { formatAssetKind, formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib'
import PipelineNodeCard from './PipelineNodeCard.svelte'
import NodeActionsMenu from './NodeActionsMenu.svelte'
import NodeFixButton, { type NodeFix } from './NodeFixButton.svelte'
import NodeOnDot from './NodeOnDot.svelte'
import AddDownstreamMenu from './AddDownstreamMenu.svelte'
import AddDownstreamPill from './AddDownstreamPill.svelte'
import {
ArrowUpRight,
Code2,
GitFork,
History,
Play,
Loader2,
ShieldCheck,
ShieldAlert,
CheckCircle2,
CircleSlash,
Trash2,
XCircle
} from 'lucide-svelte'
import type { ScriptLang } from '$lib/gen'
import { enterpriseLicense } from '$lib/stores'
import { sendUserToast, type Item } from '$lib/utils'
import type { PipelineOutputKind } from './pipelineTemplates'
import type { DbtAssetProvenance } from './types'
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import RunStateChip from './RunStateChip.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { upstreamScriptItems } from './upstreamScriptItems'
import type { RunnableRunState } from './activeRunnables.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
// Shape used for both the data prop and the run callback. Drafts carry
// `content` / `language` so the page-level run handler can dispatch to
// `runScriptPreview` instead of `runScriptByPath` (which 404s for
// non-deployed scripts).
export type AssetUpstreamChips =
| { none: true }
| {
multiple: true
/** The producing scripts; one when only its triggers are ambiguous. */
scripts: Array<{
runnableId: string
path: string
summary?: string
language?: ScriptLang
unsaved?: boolean
onOpen: () => void
}>
selected?: boolean
}
| {
runnableId: string
path: string
summary?: string
language?: ScriptLang
unsaved?: boolean
runState?: RunnableRunState
trigger: {
label: string
missing?: boolean
draft?: boolean
/** Opens the trigger's editor; unset for what has none (manual, asset change). */
onOpen?: () => void
selected?: boolean
}
onOpen: () => void
selected?: boolean
}
export type AssetProducer = {
kind: 'script' | 'flow'
path: string
unsaved?: boolean
}
interface Props {
data: {
asset_kind: AssetKind
path: string
// Fork workspaces: 'fork' = materialized in this fork, 'deferred' =
// reads fall back to the parent workspace's current data.
fork_materialization?: 'fork' | 'deferred'
// Set on an SCD2 `<dim>_current` companion view: the base `<dim>` path
// its producer materializes with `// materialize … history`. Drives the
// "current view of <dim>" marker so it reads as a derived node, not an
// unrelated table.
derived_from?: string
// Why the node is misconfigured, when it is: renders it red.
error?: string
// dbt provenance when this warehouse table is a dbt node: which model
// it is, how dbt materializes it, its tags and its generic tests.
dbt?: DbtAssetProvenance
/** Hovering the dbt chip emphasizes the project node that
* materializes this model — the association the graph deliberately
* does not draw as an edge. */
onDbtHover?: (on: boolean) => void
/** Clicking it selects that project node. */
onDbtSelect?: () => void
onAddScript?: (
asset: { kind: AssetKind; path: string },
language: ScriptLang,
scriptPath: string,
outputKind: PipelineOutputKind,
aiPrompt?: string,
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
) => void
pathPrefix?: string
defaultPathSuffix?: string
/** Starts dragging the "+" onto a script (subscribing it to this asset)
* or onto the blank canvas (opening the add menu there). */
onStartFeedDrag?: (e: PointerEvent) => void
// Producer scripts/flows that write to this asset, supplied by
// the canvas from the graph's write/rw edges. Drives the on-hover
// "Run" button. Includes unsaved/draft producers so the button
// shows up in fresh pipelines.
producers?: AssetProducer[]
// Page-supplied callback that knows how to actually run a
// producer. Takes a producer and returns the new job's id.
// Implementation can dispatch to `runScriptByPath` for saved
// scripts and `runScriptPreview` for drafts (using the locally-
// cached draft content). Without this callback, the play button
// is hidden — runs only make sense in editor contexts.
onRunProducer?: (producer: AssetProducer) => Promise<string | undefined>
// True when a producer materializing this asset declares `// data_test`
// checks — the asset's write is guarded. Drives the data-test outcome
// badge, whose meaning differs by edition (EE rolls a failing write
// back; CE publishes it anyway).
dataTestGuarded?: boolean
// True when the latest observed run of a producer materializing this
// asset failed. Escalates the guard badge from "protected" to a
// failed-run outcome (rolled-back on EE, published-anyway on CE).
producerFailed?: boolean
// Monotonic nonce bumped by the replay player when this asset's
// producer just recomputed it. A change triggers a one-shot green
// fade so a freshly-written table stands out as the run progresses.
recomputePulse?: number
// What the run being viewed is doing to this relation. dbt records it
// per model as it walks the DAG, so the graph moves with the run
// instead of only settling once the job ends.
runStatus?: 'running' | 'materialized' | 'failed'
runRowCount?: number | null
/** Set in the assets-only view, where the producing script and its
* trigger are folded into the asset: `none` for an asset nothing in the
* pipeline writes, `multiple` when the producer or its trigger is
* ambiguous. */
upstream?: AssetUpstreamChips
/** What is wrong with the node, and how to fix it: replaces the "+". */
fix?: NodeFix
/** Assets-only: errors of the scripts and triggers folded into this node. */
foldedFixes?: NodeFix[]
/** Assets-only: starts dragging the top dot onto an asset, subscribing the
* scripts that build this one to it. */
onStartOnDrag?: (e: PointerEvent) => void
/** The assets-only node for the scripts that build no asset. */
noAsset?: boolean
/** No script in the workspace writes it; a flow's write fires no asset trigger. */
neverWritten?: boolean
/** Card width in the assets-only view, sized to its text. */
width?: number
/** Assets-only view: deletes the producing script and trigger no other
* asset shares; `deleteCount` is how many that is. */
onDeleteUpstream?: () => void
deleteCount?: number
deleteVerb?: 'Delete' | 'Archive'
/** Why nothing can be deleted, when the producer is shared. */
deleteBlocked?: string
}
// SvelteFlow injects this on the node component when the user clicks
// the node. Combined with our own `hovered` state to drive the
// run-button visibility — same affordance as the flow editor's
// hover/select-revealed step actions.
selected?: boolean
}
let { data, selected = false }: Props = $props()
let asset = $derived({ kind: data.asset_kind, path: data.path })
let hovered = $state(false)
let running = $state(false)
let producers = $derived(data.producers ?? [])
let scriptProducers = $derived(producers.filter((p) => p.kind === 'script'))
let canRun = $derived(scriptProducers.length > 0 && data.onRunProducer != undefined)
// Visible iff the user has opted into the node either by selecting it
// (svelteflow's `selected` prop) or hovering. While running, we keep it
// pinned so the loader doesn't disappear out from under the cursor.
let showActions = $derived((selected || hovered || running) && canRun)
async function runProducers(e: MouseEvent) {
e.stopPropagation()
if (!$operatingWorkspace || running || !data.onRunProducer) return
if (scriptProducers.length === 0) return
running = true
const handler = data.onRunProducer
try {
// Fire every script producer in parallel — matches what would
// happen if every upstream trigger fired together. The handler
// internally dispatches to runScriptByPath / runScriptPreview
// based on producer.unsaved. No success toast — the runs panel
// auto-selects the new job and shows status/logs/output, so
// the toast was redundant.
await Promise.all(scriptProducers.map((p) => handler(p)))
} catch (err: any) {
sendUserToast(`Failed to run: ${err.body ?? err.message}`, true)
} finally {
running = false
}
}
// A red asset needs fixing where its error comes from, not a downstream step;
// and a step downstream of what nothing writes would never run on its writes.
let showAdd = $derived(data.onAddScript != undefined && !data.error && !data.neverWritten)
let menuItems: Item[] = $derived(
data.onDeleteUpstream
? [
{
displayName: `${data.deleteVerb ?? 'Delete'} ${data.deleteCount} item${data.deleteCount === 1 ? '' : 's'}`,
icon: Trash2,
type: 'delete' as const,
action: () => data.onDeleteUpstream?.()
}
]
: data.deleteBlocked
? [
{
displayName: `Can't delete: ${data.deleteBlocked}`,
icon: Trash2,
disabled: true
}
]
: []
)
let upstream = $derived(data.upstream)
let noAssetTitle = $derived.by(() => {
const n = upstream && 'scripts' in upstream ? upstream.scripts.length : 1
return `${n} script${n === 1 ? '' : 's'}`
})
const CHIP_CLASS =
'shrink-0 h-5 min-w-5 rounded-md border bg-surface text-secondary hover:bg-surface-hover border-gray-300 dark:border-gray-600'
const CHIP_SELECTED =
'bg-surface-accent-selected border-border-selected hover:bg-surface-accent-selected text-accent'
let producer = $derived(upstream && 'path' in upstream ? upstream : undefined)
let coProducers = $derived(upstream && 'scripts' in upstream ? upstream.scripts : [])
let coSelected = $derived(!!(upstream && 'scripts' in upstream && upstream.selected))
let coProducerItems = $derived(
upstreamScriptItems(coProducers, (path) => coProducers.find((s) => s.path === path)?.onOpen())
)
let scriptMenuOpen = $state(false)
// dbt badge. `materialized` is dbt's own word rather than the Windmill
// strategy because `view` and `ephemeral` have no strategy, and showing the
// dbt word keeps the node legible to someone reading their own project.
let dbtLabel = $derived(data.dbt?.materialized ?? data.dbt?.resource_type)
let dbtTitle = $derived.by(() => {
const d = data.dbt
if (!d) return ''
const lines = [`dbt ${d.resource_type}: ${d.unique_id}`]
if (d.materialized) {
const strategy = d.materialize_strategy ? ` -> ${d.materialize_strategy}` : ''
lines.push(`materialized: ${d.materialized}${strategy}`)
}
if (d.tags?.length) lines.push(`tags: ${d.tags.join(', ')}`)
for (const t of d.data_tests ?? []) {
const col = t.column ? ` on ${t.column}` : ''
lines.push(`test ${t.kind}${col}${t.severity ? ` [${t.severity}]` : ''}`)
}
const cols = Object.entries(d.columns ?? {})
if (cols.length) {
lines.push(`columns: ${cols.map(([c, desc]) => (desc ? `${c} (${desc})` : c)).join(', ')}`)
}
if (d.freshness) {
const f = d.freshness as Record<string, { count?: number; period?: string }>
const window = (k: string) =>
f[k]?.count != null
? `${k.replace('_after', '')} after ${f[k].count}${f[k].period?.[0] ?? ''}`
: ''
const windows = ['warn_after', 'error_after'].map(window).filter(Boolean)
if (windows.length) lines.push(`freshness: ${windows.join(', ')}`)
}
if (d.description) lines.push(d.description)
return lines.join('\n')
})
// Data-test outcome badge. Only guarded assets show it. The write's fate on a
// failing test differs by edition — surface which one applies so a shared
// parent/fork table name can't hide a silently-published bad version.
let isEE = $derived(!!$enterpriseLicense)
let showGuardBadge = $derived(data.dataTestGuarded === true)
let guardFailed = $derived(data.producerFailed === true)
let GuardIcon = $derived(isEE ? ShieldCheck : ShieldAlert)
// Filled + colored when the last run failed (the actionable state); a quiet
// ring at rest so the badge doesn't shout on every healthy guarded asset.
let guardClass = $derived(
guardFailed
? isEE
? 'bg-amber-500 text-white border-amber-600'
: 'bg-red-500 text-white border-red-600'
: isEE
? 'bg-surface-secondary text-emerald-600 dark:text-emerald-400 border-emerald-500/60'
: 'bg-surface-secondary text-amber-600 dark:text-amber-400 border-amber-500/60'
)
// Failed copy speaks to the edition's write policy, not the failure cause:
// `producerFailed` is a generic job failure (could be a runtime/worker error,
// not a data-test violation), so we don't assert "failed its data tests".
let guardTitle = $derived(
guardFailed
? isEE
? 'Last run failed — Enterprise rolls a failed materialize back, so the previous version is left live.'
: 'Last run failed — Community Edition does not roll a failed materialize back (Enterprise does), so a failing write may be left live. Verify the table.'
: isEE
? 'Guarded by data tests: a failing write is rolled back, keeping the previous version live.'
: 'Guarded by data tests, but Community Edition does not block on failure — a failing write is still published. Rollback is Enterprise-only.'
)
</script>
{#snippet scriptChip(sc: {
path: string
summary?: string
language?: ScriptLang
unsaved?: boolean
selected?: boolean
onOpen: () => void
})}
<button
type="button"
class={twMerge(
CHIP_CLASS,
'grid place-items-center px-1',
sc.unsaved && 'border-dashed border-gray-400 dark:border-gray-500',
sc.selected && CHIP_SELECTED
)}
title={`Open ${sc.summary ? `${sc.summary} (${sc.path})` : sc.path}${sc.unsaved ? ' · draft' : ''}`}
aria-label="Open the script that produces this asset"
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation()
sc.onOpen()
}}
>
{#if sc.language}
<LanguageIcon lang={sc.language} width={12} height={12} />
{:else}
<Code2 size={12} />
{/if}
</button>
{/snippet}
{#snippet upstreamRow()}
{#if data.foldedFixes?.length}
<!-- A folded script or trigger is misconfigured: its Fix, one at a time. -->
<NodeFixButton
chip
fix={data.foldedFixes[0]}
label={`${data.foldedFixes.length} error${data.foldedFixes.length === 1 ? '' : 's'} · Fix`}
/>
{:else if upstream}
{@const trigger = producer?.trigger}
{@const chipClass = twMerge(
'truncate rounded-md border px-1.5 py-0.5 text-3xs leading-none font-normal',
trigger?.missing
? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300'
: 'border-gray-300 dark:border-gray-600 text-secondary',
trigger?.draft && 'border-dashed',
trigger?.onOpen && 'bg-surface hover:bg-surface-hover cursor-pointer',
trigger?.selected && CHIP_SELECTED
)}
{#if trigger?.onOpen}
<button
type="button"
class={chipClass}
title={trigger.missing
? `No ${trigger.label} trigger targets ${producer?.path} yet: click to create one`
: `Edit the ${trigger.label} trigger`}
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation()
trigger.onOpen?.()
}}
>
{trigger.label}{trigger.draft ? ' · draft' : ''}
</button>
{:else}
<span class={chipClass}>
{#if 'none' in upstream}
External source
{:else if trigger}
{trigger.label}{trigger.draft ? ' · draft' : ''}
{:else}
Multiple upstream nodes
{/if}
</span>
{/if}
{/if}
{/snippet}
<!-- onmouseenter/leave on the wrapper (not the inner card) so the run
button — which floats outside the card — keeps the hover state alive
when the cursor moves between the card and the button. -->
<div
class="relative"
onmouseenter={() => (hovered = true)}
onmouseleave={() => (hovered = false)}
role="presentation"
>
{#if data.recomputePulse !== undefined}
{#key data.recomputePulse}
<!-- One-shot green wash when the replay player recomputes this asset.
Keyed on the nonce so a repeat recompute replays the animation. -->
<div class="wm-recompute-flash pointer-events-none absolute inset-0 z-10 rounded-md"></div>
{/key}
{/if}
<NodeActionsMenu
items={menuItems}
hover={hovered}
kebabClass={showGuardBadge ? '-right-8' : undefined}
>
<PipelineNodeCard
kindLabel={data.noAsset ? 'No asset' : formatAssetKind(asset)}
title={data.noAsset ? noAssetTitle : formatShortAssetPath(asset)}
tooltip={data.noAsset
? 'Scripts that build no asset'
: data.error
? `${data.path}: ${data.error}`
: data.path}
{selected}
tone={data.error ? 'error' : undefined}
subtitle={upstream ? upstreamRow : undefined}
width={data.width}
surface={upstream ? 'primary' : 'secondary'}
>
{#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. -->
{#if data.noAsset}
<CircleSlash size={14} class="text-tertiary" />
{:else}
<AssetGenericIcon
assetKind={data.asset_kind}
class={data.error
? 'text-red-600 dark:text-red-200'
: selected
? 'text-accent'
: 'text-blue-600 dark:text-blue-400'}
size="14px"
/>
{/if}
{/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
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}
{/if}
{/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
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
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}
{#if producer}
{#if producer.runState}
<RunStateChip runState={producer.runState} class="mr-1 h-5 px-1.5 rounded-md" />
{/if}
{@render scriptChip(producer)}
{:else if coProducers.length === 1}
{@render scriptChip({ ...coProducers[0], selected: coSelected })}
{:else if coProducers.length > 1}
<!-- Several scripts build this table: the first two languages and a
count, opening a menu to pick which one to open. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onpointerdown={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<DropdownV2
items={coProducerItems}
placement="bottom-end"
bind:open={scriptMenuOpen}
fixedHeight={false}
usePointerDownOutside
enableFlyTransition
>
{#snippet buttonReplacement()}
<span
class={twMerge(
CHIP_CLASS,
'flex items-center gap-0.5 px-1',
(coSelected || scriptMenuOpen) && CHIP_SELECTED
)}
title={`Built by ${coProducers.length} scripts: pick one to open`}
>
{#each coProducers.slice(0, 2) as s (s.path)}
{#if s.language}
<LanguageIcon lang={s.language} width={12} height={12} />
{:else}
<Code2 size={12} />
{/if}
{/each}
{#if coProducers.length > 2}
<span class="text-3xs leading-none">+{coProducers.length - 2}</span>
{/if}
</span>
{/snippet}
</DropdownV2>
</div>
{/if}
{/snippet}
</PipelineNodeCard>
</NodeActionsMenu>
{#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
with the other node affordances. Shield = guarded; its fill escalates to
amber/red when the last run failed, encoding the edition's write policy. -->
<div
class={twMerge(
'absolute -top-2 -right-2 z-10 rounded-full w-5 h-5 grid place-items-center border shadow-sm',
guardClass
)}
title={guardTitle}
>
<GuardIcon size={12} strokeWidth={2.25} />
</div>
{/if}
{#if showActions}
<!-- Run button revealed on hover/select. Floats off the LEFT edge —
visually closer to the upstream producer it triggers (which lays
out above-and-toward-the-arrow-source in the DAG), and avoids
colliding with badges that may sit on the right of future asset
cards. Drafts are runnable too via runScriptPreview, so no
greyed-out state — the page-supplied callback handles the
dispatch. -->
<div class="absolute -left-3 top-1/2 -translate-y-1/2 z-10">
<button
type="button"
onclick={runProducers}
disabled={running}
class="bg-blue-500 hover:bg-blue-600 disabled:opacity-60 text-white rounded-full w-6 h-6 grid place-items-center shadow border-2 border-surface-secondary leading-none"
title={scriptProducers.length > 1
? `Run ${scriptProducers.length} upstream scripts`
: `Run ${scriptProducers[0]?.path ?? ''}${scriptProducers[0]?.unsaved ? ' (draft, runs as preview)' : ''}`}
>
{#if running}
<Loader2 size={14} class="animate-spin" />
{:else}
<!-- translate-x-px nudges the play triangle off-center to
compensate for its own asymmetric viewBox; without it
the triangle's centroid lands ~1px left of the button
center and reads as misaligned. -->
<Play size={14} strokeWidth={2.5} class="translate-x-px" />
{/if}
</button>
</div>
{/if}
{#if data.onStartOnDrag}
<NodeOnDot onStart={data.onStartOnDrag} visible={hovered} />
{/if}
{#if data.fix}
<!-- A red node's way out replaces its "+": adding downstream of a broken
node is not what it needs. -->
<div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10">
<NodeFixButton fix={data.fix} />
</div>
{:else if showAdd}
<!-- Always-visible + on the asset's bottom edge for downstream pipeline-
script creation, half-overlapping it like the flow editor's
between-step inserter. -->
<div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10">
<AddDownstreamMenu
asset={{ kind: data.asset_kind, path: data.path }}
onAddScript={data.onAddScript!}
pathPrefix={data.pathPrefix}
defaultPathSuffix={data.defaultPathSuffix}
>
{#snippet trigger({ open })}
<!-- A click opens the menu; a drag goes to the canvas, which
tells a real drag from a click by how far the pointer moves. -->
<button
type="button"
class="nopan nodrag block"
onclick={(e) => e.stopPropagation()}
onpointerdown={(e) => {
if (e.button !== 0) return
e.preventDefault()
data.onStartFeedDrag?.(e)
}}
aria-label="Add downstream step"
title={data.onStartFeedDrag
? 'Click to add a downstream step, or drag onto a script to run it after each write'
: undefined}
>
<AddDownstreamPill expanded={open} />
</button>
{/snippet}
</AddDownstreamMenu>
</div>
{/if}
</div>
<Handle type="target" position={Position.Top} isConnectable={false} />
<Handle type="source" position={Position.Bottom} isConnectable={false} />
<style>
.wm-recompute-flash {
animation: wm-recompute-flash 1.2s ease-out 1;
}
@keyframes wm-recompute-flash {
0% {
background-color: rgba(16, 185, 129, 0);
}
15% {
background-color: rgba(16, 185, 129, 0.45);
}
100% {
background-color: rgba(16, 185, 129, 0);
}
}
</style>