mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
feat: a No asset node per script, banner-only when writes start it; no On asset change chip
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
b621475c77
commit
2d72eeb765
@@ -31,7 +31,6 @@
|
||||
import {
|
||||
assetsOnlyView,
|
||||
upstreamDeletion,
|
||||
NO_ASSET_NODE_ID,
|
||||
withoutPassiveReads,
|
||||
type AssetUpstream,
|
||||
type AssetUpstreamDelete
|
||||
@@ -578,7 +577,7 @@
|
||||
if (!n) return id.replace(/^[^:]+:/, '')
|
||||
if (n.type === 'asset')
|
||||
return formatShortAssetPath({ kind: n.data.asset_kind, path: n.data.path })
|
||||
if (n.type === 'no-asset') return 'the scripts that build no asset'
|
||||
if (n.type === 'no-asset') return n.data.noAssetName
|
||||
if (n.type === 'trigger') return n.data.summary || n.data.ref
|
||||
return n.data.summary || n.data.path
|
||||
}
|
||||
@@ -1316,13 +1315,15 @@
|
||||
scriptPath: string
|
||||
): {
|
||||
label: string
|
||||
/** Nothing to show: the edges into the node already say it runs on writes. */
|
||||
hidden?: boolean
|
||||
nodeId?: string
|
||||
missing?: boolean
|
||||
draft?: boolean
|
||||
onOpen?: () => void
|
||||
} {
|
||||
if (!t) return { label: 'Manual' }
|
||||
if (t.kind === 'asset') return { label: 'On asset change' }
|
||||
if (t.kind === 'asset') return { label: '', hidden: true }
|
||||
const kind = t.kind as NativeTriggerKind
|
||||
const style = TRIGGER_NODE_STYLE[kind]
|
||||
const label =
|
||||
@@ -1415,22 +1416,21 @@
|
||||
]
|
||||
const foldedErrors = foldedIds.filter((id) => nodeFixes?.has(id)).length
|
||||
const asset = { kind: data.asset_kind, path: data.path }
|
||||
const scriptCount = u?.multiple ? u.runnableIds.length : 1
|
||||
// Chips this estimate does not model keep the regular width.
|
||||
const width =
|
||||
upstream && !data.dbt && !data.fork_materialization && !data.derived_from && !data.runStatus
|
||||
? assetsOnlyNodeWidth({
|
||||
kind: data.noAsset ? 'No asset' : formatAssetKind(asset),
|
||||
title: data.noAsset
|
||||
? `${scriptCount} script${scriptCount === 1 ? '' : 's'}`
|
||||
: formatShortAssetPath(asset),
|
||||
title: data.noAsset ? data.noAssetName : formatShortAssetPath(asset),
|
||||
chip: foldedErrors
|
||||
? `${foldedErrors} errors · Fix`
|
||||
: 'none' in upstream
|
||||
? 'External source'
|
||||
: 'multiple' in upstream
|
||||
? 'Multiple upstream nodes'
|
||||
: `${upstream.trigger.label}${upstream.trigger.draft ? ' · draft' : ''}`,
|
||||
: upstream.trigger.hidden
|
||||
? ''
|
||||
: `${upstream.trigger.label}${upstream.trigger.draft ? ' · draft' : ''}`,
|
||||
chipIcon: foldedErrors > 0,
|
||||
chipEdit:
|
||||
!foldedErrors &&
|
||||
@@ -1489,12 +1489,22 @@
|
||||
const nodes = m.nodes
|
||||
.filter((n) => n.id === ADD_NODE_ID || v.nodeIds.has(n.id))
|
||||
.map((n) => (n.type === 'asset' ? { ...n, data: fold(n.data, v.upstream.get(n.id)) } : n))
|
||||
if (v.hasNoAssetNode) {
|
||||
nodes.push({
|
||||
id: NO_ASSET_NODE_ID,
|
||||
type: 'no-asset',
|
||||
data: fold({ noAsset: true }, v.upstream.get(NO_ASSET_NODE_ID))
|
||||
})
|
||||
for (const id of v.noAssetNodeIds) {
|
||||
const u = v.upstream.get(id)
|
||||
const scriptId = u && !u.multiple ? u.runnableId : u?.runnableIds[0]
|
||||
const data = fold(
|
||||
{ noAsset: true, noAssetName: scriptId?.split('/').pop() ?? 'script' },
|
||||
u
|
||||
)
|
||||
// Run only by writes to what it reads, the edges into it already say how
|
||||
// it starts: the script's banner alone. Another trigger, or an error to
|
||||
// fix, needs the card's chip row.
|
||||
const bannerOnly =
|
||||
!!u &&
|
||||
!u.multiple &&
|
||||
u.trigger?.kind === 'asset' &&
|
||||
!(data.foldedIds as string[]).some((f) => nodeFixes?.has(f))
|
||||
nodes.push({ id, type: 'no-asset', data: bannerOnly ? { ...data, bannerOnly } : data })
|
||||
}
|
||||
const edges: BuiltEdge[] = v.edges.map((e) => ({ ...e, kind: e.kind as BuiltEdge['kind'] }))
|
||||
if (nodes.some((n) => n.id === ADD_NODE_ID)) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import NodeActionsMenu from './NodeActionsMenu.svelte'
|
||||
import NodeFixButton, { type NodeFix } from './NodeFixButton.svelte'
|
||||
import NodeOnDot from './NodeOnDot.svelte'
|
||||
import { PIPELINE_NODE_HEADER } from './assetGraphLayout'
|
||||
import AddDownstreamMenu from './AddDownstreamMenu.svelte'
|
||||
import AddDownstreamPill from './AddDownstreamPill.svelte'
|
||||
import {
|
||||
@@ -68,6 +69,8 @@
|
||||
runState?: RunnableRunState
|
||||
trigger: {
|
||||
label: string
|
||||
/** No chip: the node's incoming edges already show what starts it. */
|
||||
hidden?: boolean
|
||||
missing?: boolean
|
||||
draft?: boolean
|
||||
/** Opens the trigger's editor; unset for what has none (manual, asset change). */
|
||||
@@ -164,6 +167,10 @@
|
||||
onStartOnDrag?: (e: PointerEvent) => void
|
||||
/** The assets-only node for the scripts that build no asset. */
|
||||
noAsset?: boolean
|
||||
/** The No-asset node's script, by its file name. */
|
||||
noAssetName?: string
|
||||
/** Assets-only: the node is only its script's banner. */
|
||||
bannerOnly?: 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. */
|
||||
@@ -242,10 +249,6 @@
|
||||
: []
|
||||
)
|
||||
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 HEADER_SELECTED = 'bg-surface-accent-selected hover:bg-surface-accent-selected text-accent'
|
||||
const CHIP_SELECTED =
|
||||
'bg-surface-accent-selected border-border-selected hover:bg-surface-accent-selected text-accent'
|
||||
@@ -465,19 +468,38 @@
|
||||
<NodeActionsMenu
|
||||
items={menuItems}
|
||||
hover={hovered}
|
||||
kebabClass={twMerge(showGuardBadge && '-right-8', hasHeader && CARD_CORNER_TOP)}
|
||||
kebabClass={twMerge(
|
||||
showGuardBadge && '-right-8',
|
||||
hasHeader && !data.bannerOnly && CARD_CORNER_TOP
|
||||
)}
|
||||
>
|
||||
{#if data.bannerOnly}
|
||||
<!-- A script that builds nothing and runs only on writes to what it reads:
|
||||
the incoming edges say how it starts, so its banner is the whole node. -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex items-stretch min-w-0 rounded-md overflow-hidden bg-surface border border-gray-200 dark:border-gray-700 drop-shadow-sm',
|
||||
(producer ? producer.selected : coSelected) &&
|
||||
'border-border-selected dark:border-border-selected'
|
||||
)}
|
||||
style="width: {data.width ?? 160}px; height: {PIPELINE_NODE_HEADER}px;"
|
||||
>
|
||||
{@render scriptHeader()}
|
||||
</div>
|
||||
{:else}
|
||||
<PipelineNodeCard
|
||||
kindLabel={data.noAsset ? 'No asset' : formatAssetKind(asset)}
|
||||
title={data.noAsset ? noAssetTitle : formatShortAssetPath(asset)}
|
||||
title={data.noAsset ? (data.noAssetName ?? '') : formatShortAssetPath(asset)}
|
||||
tooltip={data.noAsset
|
||||
? 'Scripts that build no asset'
|
||||
? 'This script builds no asset'
|
||||
: data.error
|
||||
? `${data.path}: ${data.error}`
|
||||
: data.path}
|
||||
{selected}
|
||||
tone={data.error ? 'error' : undefined}
|
||||
subtitle={upstream ? upstreamRow : undefined}
|
||||
subtitle={data.foldedFixes?.length || (upstream && !producer?.trigger?.hidden)
|
||||
? upstreamRow
|
||||
: undefined}
|
||||
header={hasHeader ? scriptHeader : undefined}
|
||||
headerSelected={producer ? !!producer.selected : coSelected}
|
||||
width={data.width}
|
||||
@@ -600,6 +622,7 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PipelineNodeCard>
|
||||
{/if}
|
||||
</NodeActionsMenu>
|
||||
{#if showGuardBadge}
|
||||
<!-- Data-test outcome badge. Floats off the TOP-RIGHT corner (opposite the
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assetsOnlyView,
|
||||
upstreamDeletion,
|
||||
NO_ASSET_NODE_ID,
|
||||
noAssetNodeId,
|
||||
withoutPassiveReads,
|
||||
type AssetUpstream
|
||||
} from './assetsOnlyView'
|
||||
@@ -64,31 +64,31 @@ describe('assetsOnlyView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the No asset node', () => {
|
||||
it('gathers scripts that build nothing, but not dbt projects or macro libraries', () => {
|
||||
describe('the No asset nodes', () => {
|
||||
it('give each script that builds nothing its own node, fed by what it reads', () => {
|
||||
const v = assetsOnlyView(
|
||||
[
|
||||
node('asset:ducklake:raw', 'asset'),
|
||||
node('script:f/p/notify', 'runnable'),
|
||||
node('asset:ducklake:a', 'asset'),
|
||||
node('asset:ducklake:b', 'asset'),
|
||||
node('script:f/p/on_a', 'runnable'),
|
||||
node('script:f/p/on_b', 'runnable'),
|
||||
node('script:f/p/dbt', 'runnable', { dbt: { model_count: 3 } }),
|
||||
node('script:f/p/macros', 'runnable', { macros: [{ name: 'm' }] })
|
||||
],
|
||||
[edge('asset:ducklake:raw', 'script:f/p/notify', 'lineage-read')]
|
||||
[
|
||||
edge('asset:ducklake:a', 'script:f/p/on_a', 'trigger-asset'),
|
||||
edge('asset:ducklake:b', 'script:f/p/on_b', 'trigger-asset')
|
||||
]
|
||||
)
|
||||
expect(v.hasNoAssetNode).toBe(true)
|
||||
expect(v.upstream.get(NO_ASSET_NODE_ID)).toMatchObject({ runnableId: 'script:f/p/notify' })
|
||||
const onA = noAssetNodeId('script:f/p/on_a')
|
||||
const onB = noAssetNodeId('script:f/p/on_b')
|
||||
expect(v.noAssetNodeIds).toEqual([onA, onB])
|
||||
expect(v.upstream.get(onA)).toMatchObject({ runnableId: 'script:f/p/on_a' })
|
||||
expect(v.edges.map((e) => `${e.source} -> ${e.target}`)).toEqual([
|
||||
`asset:ducklake:raw -> ${NO_ASSET_NODE_ID}`
|
||||
`asset:ducklake:a -> ${onA}`,
|
||||
`asset:ducklake:b -> ${onB}`
|
||||
])
|
||||
})
|
||||
|
||||
it('is absent when every script builds an asset', () => {
|
||||
const v = assetsOnlyView(
|
||||
[node('asset:ducklake:a', 'asset'), node('script:f/p/a', 'runnable')],
|
||||
[edge('script:f/p/a', 'asset:ducklake:a', 'lineage-write')]
|
||||
)
|
||||
expect(v.hasNoAssetNode).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upstreamDeletion', () => {
|
||||
|
||||
@@ -14,8 +14,10 @@ export type ViewEdge = {
|
||||
reactive?: boolean
|
||||
}
|
||||
|
||||
/** The assets-only node standing for the scripts that build no asset. */
|
||||
export const NO_ASSET_NODE_ID = 'no-asset'
|
||||
/** The assets-only node standing for a script that builds no asset: one per
|
||||
* script, so scripts reacting to different inputs stay apart. */
|
||||
export const noAssetNodeId = (runnableId: string) => `no-asset:${runnableId}`
|
||||
export const isNoAssetNode = (id: string) => id.startsWith('no-asset:')
|
||||
|
||||
/** The script and trigger an assets-only asset's delete removes. */
|
||||
export type AssetUpstreamDelete = {
|
||||
@@ -49,8 +51,9 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
|
||||
nodeIds: Set<string>
|
||||
edges: Array<ViewEdge>
|
||||
upstream: Map<string, AssetUpstream>
|
||||
/** Some scripts build no asset; their upstream is under `NO_ASSET_NODE_ID`. */
|
||||
hasNoAssetNode: boolean
|
||||
/** A node per script that builds no asset (`noAssetNodeId`), its upstream
|
||||
* under that id. */
|
||||
noAssetNodeIds: string[]
|
||||
} {
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]))
|
||||
const inputsOf = new Map<string, E[]>()
|
||||
@@ -129,43 +132,46 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
|
||||
upstream.set(assetId, describe([...new Set(writes.map((w) => w.source))]))
|
||||
}
|
||||
|
||||
// Scripts that build no asset have nowhere to fold into, so they share one
|
||||
// "No asset" node, fed by what they read. dbt projects (whose writes are not
|
||||
// drawn) and macro libraries (which build nothing by design) stay out of it.
|
||||
// Scripts that build no asset have nowhere to fold into, so each gets a "No
|
||||
// asset" node, fed by what it reads. dbt projects (whose writes are not drawn)
|
||||
// and macro libraries (which build nothing by design) get none.
|
||||
const writers = new Set([...producersOf.values()].flat().map((w) => w.source))
|
||||
const orphans = nodes
|
||||
.filter(
|
||||
(n) => n.type === 'runnable' && !writers.has(n.id) && !n.data?.dbt && !n.data?.macros?.length
|
||||
)
|
||||
.map((n) => n.id)
|
||||
if (orphans.length > 0) {
|
||||
upstream.set(NO_ASSET_NODE_ID, describe(orphans))
|
||||
for (const id of orphans) {
|
||||
for (const r of inputsOf.get(id) ?? []) {
|
||||
const eid = `flow:${r.source}->${NO_ASSET_NODE_ID}`
|
||||
const prev = out.get(eid)
|
||||
const reactive = triggered.has(`${r.source}\n${id}`)
|
||||
if (prev) {
|
||||
prev.unsaved = prev.unsaved && !!r.unsaved
|
||||
prev.reactive ||= reactive
|
||||
if (!prev.via?.includes(id)) prev.via = [...(prev.via ?? []), id]
|
||||
} else {
|
||||
out.set(eid, {
|
||||
id: eid,
|
||||
source: r.source,
|
||||
target: NO_ASSET_NODE_ID,
|
||||
kind: 'asset-flow',
|
||||
unsaved: !!r.unsaved,
|
||||
reactive,
|
||||
via: [id]
|
||||
})
|
||||
}
|
||||
for (const id of orphans) {
|
||||
const nodeId = noAssetNodeId(id)
|
||||
upstream.set(nodeId, describe([id]))
|
||||
for (const r of inputsOf.get(id) ?? []) {
|
||||
const eid = `flow:${r.source}->${nodeId}`
|
||||
const reactive = triggered.has(`${r.source}\n${id}`)
|
||||
const prev = out.get(eid)
|
||||
if (prev) {
|
||||
prev.unsaved = prev.unsaved && !!r.unsaved
|
||||
prev.reactive ||= reactive
|
||||
} else {
|
||||
out.set(eid, {
|
||||
id: eid,
|
||||
source: r.source,
|
||||
target: nodeId,
|
||||
kind: 'asset-flow',
|
||||
unsaved: !!r.unsaved,
|
||||
reactive,
|
||||
via: [id]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeIds = new Set(nodes.filter((n) => n.type === 'asset').map((n) => n.id))
|
||||
return { nodeIds, edges: [...out.values()], upstream, hasNoAssetNode: orphans.length > 0 }
|
||||
return {
|
||||
nodeIds,
|
||||
edges: [...out.values()],
|
||||
upstream,
|
||||
noAssetNodeIds: orphans.map(noAssetNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
/** What deleting an asset's upstream removes: the script unless another asset
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describeSchedule } from '$lib/utils/describeCron'
|
||||
import { NO_ASSET_NODE_ID } from './assetsOnlyView'
|
||||
import { isNoAssetNode } from './assetsOnlyView'
|
||||
|
||||
/** What a hovered edge says: "`subject` `phrase` `object`", then a note. */
|
||||
export type EdgeDescription = { subject: string; phrase: string; object?: string; note?: string }
|
||||
@@ -76,7 +76,7 @@ export function describeEdge(
|
||||
? `Each write to ${src} reruns it.`
|
||||
: `A write to ${src} does not rerun it.`
|
||||
// The scripts that build nothing only read: name them, not the node.
|
||||
if (e.target === NO_ASSET_NODE_ID) {
|
||||
if (isNoAssetNode(e.target)) {
|
||||
return { subject: src, phrase: 'is read by', object: via || tgt, note: rerun }
|
||||
}
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user