mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-27 00:02:35 +00:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
713 lines
28 KiB
Svelte
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">· {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>
|