mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
feat: describe a pipeline edge's relationship on hover
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
26781d66b9
commit
358b7dec44
@@ -28,6 +28,7 @@
|
||||
type AssetUpstreamDelete
|
||||
} from './assetsOnlyView'
|
||||
import { assetsOnlyNodeWidth } from './assetNodeWidth'
|
||||
import { describeEdge } from './edgeDescription'
|
||||
import { isAssetsOnlyFolder, setAssetsOnlyFolder } from './assetsOnlyFolders'
|
||||
import { formatAssetKind, formatShortAssetPath } from '$lib/components/assets/lib'
|
||||
import { TRIGGER_NODE_STYLE } from './TriggerNode.svelte'
|
||||
@@ -267,6 +268,34 @@
|
||||
// Right-click on the empty canvas: a menu at the pointer whose entry opens the
|
||||
// add-data-source menu there too.
|
||||
let paneMenu = $state<{ x: number; y: number } | undefined>(undefined)
|
||||
|
||||
// Hovering an edge names the relationship it draws, after a short pause so
|
||||
// sweeping the pointer across the canvas doesn't flash popovers.
|
||||
let hoveredEdge = $state<{ id: string; x: number; y: number } | undefined>(undefined)
|
||||
let edgeHoverTimer: ReturnType<typeof setTimeout> | undefined
|
||||
function onEdgeEnter({ edge, event }: { edge: Edge; event: PointerEvent }) {
|
||||
clearTimeout(edgeHoverTimer)
|
||||
const at = { id: edge.id, x: event.clientX, y: event.clientY }
|
||||
edgeHoverTimer = setTimeout(() => (hoveredEdge = at), 300)
|
||||
}
|
||||
function onEdgeLeave() {
|
||||
clearTimeout(edgeHoverTimer)
|
||||
hoveredEdge = undefined
|
||||
}
|
||||
let hoveredEdgeText = $derived.by(() => {
|
||||
const e = hoveredEdge && view.edges.find((x) => x.id === hoveredEdge!.id)
|
||||
if (!e) return undefined
|
||||
const byId = new Map(view.nodes.map((n) => [n.id, n]))
|
||||
const nameOf = (id: string) => {
|
||||
const n = byId.get(id)
|
||||
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 === 'trigger') return n.data.summary || n.data.ref
|
||||
return n.data.summary || n.data.path
|
||||
}
|
||||
return describeEdge(e, nameOf, (id) => byId.get(id)?.data)
|
||||
})
|
||||
let addMenuAt = $state<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
let addMenuSignal = $state(0)
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
@@ -332,6 +361,8 @@
|
||||
// whole lib when pulled in via `// use`) — rendered as a ƒ badge.
|
||||
macro_names?: string[]
|
||||
via_use?: boolean
|
||||
// Assets-only edges: the scripts folded between the two assets.
|
||||
via?: string[]
|
||||
}
|
||||
|
||||
// Graph-id of the script the user just launched (zero-latency hint),
|
||||
@@ -1635,6 +1666,8 @@
|
||||
defaultEdgeOptions={{ type: 'asset' }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
onnodeclick={handleNodeClick}
|
||||
onedgepointerenter={onEdgeEnter}
|
||||
onedgepointerleave={onEdgeLeave}
|
||||
--background-color={false}
|
||||
>
|
||||
<div class="absolute inset-0 !bg-surface-secondary h-full"></div>
|
||||
@@ -1719,6 +1752,21 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hoveredEdge && hoveredEdgeText}
|
||||
{@const d = hoveredEdgeText}
|
||||
<div
|
||||
class="fixed z-50 max-w-80 rounded-md border bg-surface px-2.5 py-1.5 text-xs text-primary shadow-md pointer-events-none"
|
||||
style="left: {hoveredEdge.x + 12}px; top: {hoveredEdge.y + 12}px;"
|
||||
transition:fly={{ duration: 120, y: -4 }}
|
||||
role="tooltip"
|
||||
>
|
||||
<span class="font-semibold text-emphasis">{d.subject}</span>
|
||||
{d.phrase}
|
||||
{#if d.object}<span class="font-semibold text-emphasis">{d.object}</span>{/if}
|
||||
{#if d.note}<div class="mt-0.5 text-2xs text-secondary">{d.note}</div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if onAddPipelineScript}
|
||||
<div class="fixed w-0 h-0" style="left: {addMenuAt.x}px; top: {addMenuAt.y}px;">
|
||||
<AddDataSourceMenu
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
path={edgePath}
|
||||
{markerEnd}
|
||||
{style}
|
||||
interactionWidth={0}
|
||||
interactionWidth={12}
|
||||
label={undefined}
|
||||
labelStyle={undefined}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,8 @@ export type ViewEdge = {
|
||||
target: string
|
||||
kind: string
|
||||
unsaved?: boolean
|
||||
/** Assets-only edges: the folded runnables the data goes through. */
|
||||
via?: string[]
|
||||
}
|
||||
|
||||
/** The assets-only node standing for the scripts that build no asset. */
|
||||
@@ -70,8 +72,19 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
|
||||
const id = `flow:${r.source}->${assetId}`
|
||||
const prev = out.get(id)
|
||||
const unsaved = !!(w.unsaved || r.unsaved)
|
||||
if (prev) prev.unsaved = prev.unsaved && unsaved
|
||||
else out.set(id, { id, source: r.source, target: assetId, kind: 'asset-flow', unsaved })
|
||||
if (prev) {
|
||||
prev.unsaved = prev.unsaved && unsaved
|
||||
if (!prev.via?.includes(w.source)) prev.via = [...(prev.via ?? []), w.source]
|
||||
} else {
|
||||
out.set(id, {
|
||||
id,
|
||||
source: r.source,
|
||||
target: assetId,
|
||||
kind: 'asset-flow',
|
||||
unsaved,
|
||||
via: [w.source]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,15 +136,19 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
|
||||
for (const r of inputsOf.get(id) ?? []) {
|
||||
const eid = `flow:${r.source}->${NO_ASSET_NODE_ID}`
|
||||
const prev = out.get(eid)
|
||||
if (prev) prev.unsaved = prev.unsaved && !!r.unsaved
|
||||
else
|
||||
if (prev) {
|
||||
prev.unsaved = prev.unsaved && !!r.unsaved
|
||||
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
|
||||
unsaved: !!r.unsaved,
|
||||
via: [id]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describeEdge } from './edgeDescription'
|
||||
|
||||
const names: Record<string, string> = {
|
||||
'asset:ducklake:main/raw': 'raw',
|
||||
'asset:ducklake:main/clean': 'clean',
|
||||
'script:f/p/clean': 'Clean',
|
||||
'trigger:schedule:f/p/daily': 'Daily'
|
||||
}
|
||||
const nameOf = (id: string) => names[id] ?? id
|
||||
const dataOf = (id: string) =>
|
||||
id === 'trigger:schedule:f/p/daily'
|
||||
? { kind: 'schedule', schedule: '0 0 12 * * *', timezone: 'UTC' }
|
||||
: undefined
|
||||
|
||||
describe('describeEdge', () => {
|
||||
it('names an asset trigger by what runs it: any write, changed or not', () => {
|
||||
expect(
|
||||
describeEdge(
|
||||
{ source: 'asset:ducklake:main/raw', target: 'script:f/p/clean', kind: 'trigger-asset' },
|
||||
nameOf,
|
||||
dataOf
|
||||
)
|
||||
).toEqual({ subject: 'Clean', phrase: 'runs after each write to', object: 'raw' })
|
||||
})
|
||||
|
||||
it("gives a schedule's cadence", () => {
|
||||
expect(
|
||||
describeEdge(
|
||||
{ source: 'trigger:schedule:f/p/daily', target: 'script:f/p/clean', kind: 'trigger-native' },
|
||||
nameOf,
|
||||
dataOf
|
||||
)?.phrase
|
||||
).toBe('runs on schedule: Every day at 12:00 UTC')
|
||||
})
|
||||
|
||||
it('names the folded script on an assets-only edge', () => {
|
||||
expect(
|
||||
describeEdge(
|
||||
{
|
||||
source: 'asset:ducklake:main/raw',
|
||||
target: 'asset:ducklake:main/clean',
|
||||
kind: 'asset-flow',
|
||||
via: ['script:f/p/clean']
|
||||
},
|
||||
nameOf,
|
||||
dataOf
|
||||
)
|
||||
).toEqual({ subject: 'raw', phrase: 'feeds', object: 'clean', note: 'Via Clean.' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describeSchedule } from '$lib/utils/describeCron'
|
||||
|
||||
/** What a hovered edge says: "`subject` `phrase` `object`", then a note. */
|
||||
export type EdgeDescription = { subject: string; phrase: string; object?: string; note?: string }
|
||||
|
||||
/** The edge fields the description reads (the canvas model's edge). */
|
||||
export type DescribedEdge = {
|
||||
source: string
|
||||
target: string
|
||||
kind: string
|
||||
unsaved?: boolean
|
||||
muted?: boolean
|
||||
missing?: boolean
|
||||
via?: string[]
|
||||
macro_names?: string[]
|
||||
via_use?: boolean
|
||||
}
|
||||
|
||||
// What starts a script, per trigger kind: "runs on <event>".
|
||||
const TRIGGER_EVENT: Record<string, string> = {
|
||||
kafka: 'Kafka message',
|
||||
mqtt: 'MQTT message',
|
||||
amqp: 'AMQP message',
|
||||
nats: 'NATS message',
|
||||
sqs: 'SQS message',
|
||||
gcp: 'Pub/Sub message',
|
||||
email: 'incoming email',
|
||||
postgres: 'Postgres event',
|
||||
webhook: 'webhook call',
|
||||
data_upload: 'data upload'
|
||||
}
|
||||
|
||||
/**
|
||||
* The relationship an edge draws, as a sentence about its two ends. `nameOf`
|
||||
* names a node, `dataOf` gives its data (a trigger's kind and cadence).
|
||||
*/
|
||||
export function describeEdge(
|
||||
e: DescribedEdge,
|
||||
nameOf: (id: string) => string,
|
||||
dataOf: (id: string) => any
|
||||
): EdgeDescription | undefined {
|
||||
const src = nameOf(e.source)
|
||||
const tgt = nameOf(e.target)
|
||||
const draft = e.unsaved ? 'Not deployed yet.' : undefined
|
||||
switch (e.kind) {
|
||||
case 'lineage-write':
|
||||
return { subject: src, phrase: 'writes to', object: tgt, note: draft }
|
||||
case 'lineage-read':
|
||||
return {
|
||||
subject: tgt,
|
||||
phrase: 'reads from',
|
||||
object: src,
|
||||
note: e.muted ? 'A write to it does not run the script (muted).' : draft
|
||||
}
|
||||
case 'trigger-asset':
|
||||
return { subject: tgt, phrase: 'runs after each write to', object: src, note: draft }
|
||||
case 'trigger-native': {
|
||||
const t = dataOf(e.source) ?? {}
|
||||
if (e.missing) {
|
||||
return {
|
||||
subject: tgt,
|
||||
phrase: `declares \`// on ${t.kind}\`, but no trigger of that kind targets it`
|
||||
}
|
||||
}
|
||||
const phrase =
|
||||
t.kind === 'schedule' && t.schedule
|
||||
? `runs on schedule: ${describeSchedule(t.schedule, t.timezone) ?? t.schedule}`
|
||||
: `runs on ${TRIGGER_EVENT[t.kind] ?? `${t.kind} trigger`}`
|
||||
return { subject: tgt, phrase, note: t.draft ? 'Draft trigger, created on save.' : draft }
|
||||
}
|
||||
case 'asset-flow': {
|
||||
const via = (e.via ?? []).map(nameOf).join(', ')
|
||||
return { subject: src, phrase: 'feeds', object: tgt, note: via ? `Via ${via}.` : undefined }
|
||||
}
|
||||
case 'dbt-ref':
|
||||
return { subject: tgt, phrase: 'references', object: src, note: 'dbt ref().' }
|
||||
case 'macro':
|
||||
return {
|
||||
subject: tgt,
|
||||
phrase: e.via_use ? 'uses the macro library' : 'uses macros from',
|
||||
object: src,
|
||||
note: e.macro_names?.length ? e.macro_names.join(', ') : undefined
|
||||
}
|
||||
case 'test-dependency':
|
||||
return {
|
||||
subject: src,
|
||||
phrase: 'must run before',
|
||||
object: tgt,
|
||||
note: 'Its data test reads what the first one builds.'
|
||||
}
|
||||
case 'data-test':
|
||||
return { subject: tgt, phrase: 'tests', object: src }
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user