Files
windmill/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte
Diego Imbert 5cdecd795c fix: fix DB Manager not working with db resources with 4+ path segments (#7809)
* support more than 3 path segments

* Fix explore db resource not working with 4+ path segments

* don't assume 3 segments

* ?table= syntax impl

* update parsers

* more nit fixes

* fix sql query

* claude nit

* Update SQLx metadata

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-02-05 14:00:15 +00:00

345 lines
12 KiB
Svelte

<script module lang="ts">
export const NODE_WITH_READ_ASSET_Y_OFFSET = 45
export const NODE_WITH_WRITE_ASSET_Y_OFFSET = 45
export const READ_ASSET_Y_OFFSET = -45
export const WRITE_ASSET_Y_OFFSET = 64
export const assetDisplaysAsInputInFlowGraph = (a: AssetWithAltAccessType) =>
!getAccessType(a) || getAccessType(a) === 'r' || getAccessType(a) === 'rw'
export const assetDisplaysAsOutputInFlowGraph = (a: AssetWithAltAccessType) =>
getAccessType(a) === 'w' || getAccessType(a) === 'rw'
let computeAssetNodesCache: [NodeDep[], ReturnType<typeof computeAssetNodes>] | undefined
type NodeDep = {
data: object & { assets?: AssetWithAltAccessType[] | undefined }
id: string
position: { x: number; y: number }
}
export function computeAssetNodes(nodes: NodeDep[]): {
newAssetNodes: (Node & NodeLayout)[]
newAssetEdges: Edge[]
// Nodes need to be offset on the y axis to make space for the asset nodes
newNodePositions: Record<string, { x: number; y: number }>
} {
if (computeAssetNodesCache && deepEqual(nodes, computeAssetNodesCache[0])) {
return computeAssetNodesCache[1]
}
const MAX_ASSET_ROW_WIDTH = 300
const ASSETS_OVERFLOWED_NODE_WIDTH = 25
const allAssetNodes: (Node & NodeLayout)[] = []
const allAssetEdges: Edge[] = []
const yPosMap: Record<number, { r?: true; w?: true }> = {}
for (const node of nodes) {
const assets = node.data.assets ?? []
if (!assets.length) continue
// Each asset can be displayed at the top and bottom
// i.e once (R or W) or twice (RW)
const inputAssets = assets.filter(assetDisplaysAsInputInFlowGraph)
const outputAssets = assets.filter(assetDisplaysAsOutputInFlowGraph)
const displayedInputAssets = inputAssets.slice(0, 3)
const displayedOutputAssets = outputAssets.slice(0, 3)
const overflowedInputAssets = inputAssets.slice(3)
const overflowedOutputAssets = outputAssets.slice(3)
// This allows calculating which nodes to offset on the y axis to
// make space for the asset nodes
if (inputAssets.length || outputAssets.length)
yPosMap[node.position.y] = yPosMap[node.position.y] ?? {}
if (inputAssets.length) yPosMap[node.position.y].r = true
if (outputAssets.length) yPosMap[node.position.y].w = true
// All asset nodes displayed on top
const inputAssetNodes: (Node & AssetN)[] = displayedInputAssets.map((asset, i) => {
let inputAssetXGap = 12
let inputAssetWidth = 165
const targetRowW =
MAX_ASSET_ROW_WIDTH -
(overflowedInputAssets.length ? ASSETS_OVERFLOWED_NODE_WIDTH + inputAssetXGap / 2 : 0)
let totalInputRowWidth = () =>
inputAssetWidth * displayedInputAssets.length +
inputAssetXGap * (displayedInputAssets.length - 1)
if (totalInputRowWidth() > MAX_ASSET_ROW_WIDTH) {
const mult = targetRowW / totalInputRowWidth()
inputAssetWidth = inputAssetWidth * mult
inputAssetXGap = inputAssetXGap * mult
}
return {
type: 'asset' as const,
parentId: node.id,
data: { asset, displayedAccessType: 'r' },
id: `${node.id}-asset-in-${asset.kind}-${asset.path}`,
width: inputAssetWidth,
position: {
x:
displayedInputAssets.length === 1
? (NODE.width - inputAssetWidth) / 2 - 10 // Ensure we see the edge
: (inputAssetWidth + inputAssetXGap) * (i - displayedInputAssets.length / 2) +
(NODE.width + inputAssetXGap) / 2 +
(overflowedInputAssets.length
? (-ASSETS_OVERFLOWED_NODE_WIDTH - inputAssetXGap) / 2
: 0),
y: READ_ASSET_Y_OFFSET
},
selectable: false
}
})
// All asset nodes displayed on the bottom
const outputAssetNodes: (Node & AssetN)[] = displayedOutputAssets.map((asset, i) => {
let outputAssetXGap = 12
let outputAssetWidth = 165
const targetRowW =
MAX_ASSET_ROW_WIDTH -
(overflowedOutputAssets.length ? ASSETS_OVERFLOWED_NODE_WIDTH + outputAssetXGap / 2 : 0)
let totalOutputRowWidth = () =>
outputAssetWidth * displayedOutputAssets.length +
outputAssetXGap * (displayedOutputAssets.length - 1)
if (totalOutputRowWidth() > MAX_ASSET_ROW_WIDTH) {
const mult = targetRowW / totalOutputRowWidth()
outputAssetWidth = outputAssetWidth * mult
outputAssetXGap = outputAssetXGap * mult
}
return {
type: 'asset' as const,
parentId: node.id,
data: { asset, displayedAccessType: 'w' },
id: `${node.id}-asset-out-${asset.kind}-${asset.path}`,
width: outputAssetWidth,
position: {
x:
displayedOutputAssets.length === 1
? (NODE.width - outputAssetWidth) / 2 - 10 // Ensure we see the edge
: (outputAssetWidth + outputAssetXGap) * (i - displayedOutputAssets.length / 2) +
(NODE.width + outputAssetXGap) / 2 +
(overflowedOutputAssets.length
? (-ASSETS_OVERFLOWED_NODE_WIDTH - outputAssetXGap) / 2
: 0),
y: WRITE_ASSET_Y_OFFSET
},
selectable: false
}
})
const inputAssetEdges: Edge[] = inputAssetNodes?.map((n) => ({
id: `${n.id}-edge`,
source: n.id ?? '',
target: n.parentId ?? '',
type: 'empty',
data: { class: '!opacity-35 dark:!opacity-20' }
}))
const outputAssetEdges: Edge[] = outputAssetNodes?.map((n) => ({
id: `${n.id}-edge`,
source: n.parentId ?? '',
target: n.id ?? '',
type: 'empty',
data: { class: '!opacity-35 dark:!opacity-20' }
}))
allAssetEdges.push(...(outputAssetEdges ?? []), ...(inputAssetEdges ?? []))
allAssetNodes.push(...(inputAssetNodes ?? []), ...(outputAssetNodes ?? []))
// If there are more than 3 assets, we create an overflow node
if (overflowedInputAssets.length)
allAssetNodes.push({
type: 'assetsOverflowed',
data: { overflowedAssets: overflowedInputAssets, displayedAccessType: 'r' },
id: `${node.id}-assets-overflowed-in`,
parentId: node.id,
width: ASSETS_OVERFLOWED_NODE_WIDTH,
position: {
x: MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
y: READ_ASSET_Y_OFFSET
}
} satisfies Node & AssetsOverflowedN)
allAssetEdges.push({
id: `${node.id}-assets-overflowed-in-edge`,
source: `${node.id}-assets-overflowed-in`,
target: node.id,
type: 'empty',
data: { class: '!opacity-35 dark:!opacity-20' }
})
if (overflowedOutputAssets.length)
allAssetNodes.push({
type: 'assetsOverflowed',
data: { overflowedAssets: overflowedOutputAssets, displayedAccessType: 'w' },
id: `${node.id}-assets-overflowed-out`,
parentId: node.id,
width: ASSETS_OVERFLOWED_NODE_WIDTH,
position: {
x: MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
y: WRITE_ASSET_Y_OFFSET
}
} satisfies Node & AssetsOverflowedN)
allAssetEdges.push({
id: `${node.id}-assets-overflowed-out-edge`,
source: node.id,
target: `${node.id}-assets-overflowed-out`,
type: 'empty',
data: { class: '!opacity-35 dark:!opacity-25' }
})
}
// Shift all nodes to make space for the new asset nodes
const sortedNewNodes = nodes
.map((n) => ({ position: { ...n.position }, id: n.id }))
.sort((a, b) => a.position.y - b.position.y)
let currentYOffset = 0
let prevYPos = NaN
for (const node of sortedNewNodes) {
if (node.position.y !== prevYPos) {
if (yPosMap[prevYPos]?.w) currentYOffset += NODE_WITH_WRITE_ASSET_Y_OFFSET
if (yPosMap[node.position.y]?.r) currentYOffset += NODE_WITH_READ_ASSET_Y_OFFSET
prevYPos = node.position.y
}
node.position.y += currentYOffset
}
let ret: ReturnType<typeof computeAssetNodes> = {
newAssetNodes: allAssetNodes,
newAssetEdges: allAssetEdges,
newNodePositions: Object.fromEntries(sortedNewNodes.map((n) => [n.id, n.position]))
}
computeAssetNodesCache = [clone(nodes), ret]
return ret
}
</script>
<script lang="ts">
import NodeWrapper from './NodeWrapper.svelte'
import type { AssetN, AssetsOverflowedN, NodeLayout } from '../../graphBuilder.svelte'
import { AlertTriangle } from 'lucide-svelte'
import {
assetEq,
formatAssetKind,
formatShortAssetPath,
getAccessType,
type AssetWithAltAccessType
} from '$lib/components/assets/lib'
import { twMerge } from 'tailwind-merge'
import type { FlowGraphAssetContext } from '$lib/components/flows/types'
import { getContext } from 'svelte'
import ExploreAssetButton, { assetCanBeExplored } from '../../../ExploreAssetButton.svelte'
import { Tooltip } from '$lib/components/meltComponents'
import { clone, pluralize } from '$lib/utils'
import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte'
import type { Edge, Node } from '@xyflow/svelte'
import { getNodeColorClasses, NODE } from '../../util'
import { globalDbManagerDrawer, userStore } from '$lib/stores'
import { deepEqual } from 'fast-equals'
import { slide } from 'svelte/transition'
import AssetColumnBadges from '$lib/components/assets/AssetColumnBadges.svelte'
interface Props {
data: AssetN['data']
}
const flowGraphAssetsCtx = getContext<FlowGraphAssetContext | undefined>('FlowGraphAssetContext')
let { data }: Props = $props()
const isSelected = $derived(assetEq(flowGraphAssetsCtx?.val.selectedAsset, data.asset))
const cachedResourceMetadata = $derived.by(() => {
if (data.asset.kind !== 'resource') return undefined
let truncatedPath = data.asset.path.split('?table=')[0]
return flowGraphAssetsCtx?.val.resourceMetadataCache[truncatedPath]
})
const usageCount = $derived(flowGraphAssetsCtx?.val.computeAssetsCount?.(data.asset))
const colors = $derived(getNodeColorClasses(undefined, isSelected))
let assetColumns = $derived(
data.asset.columns &&
Object.fromEntries(
Object.entries(data.asset.columns).filter(
([_, accessType]) => accessType && accessType === data.displayedAccessType
)
)
)
</script>
<NodeWrapper wrapperClass="bg-surface-secondary rounded-md">
{#snippet children({ darkMode })}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<Tooltip customBgClass="bg-surface-tertiary">
<div
class={twMerge(
'h-6 flex items-center rounded-md drop-shadow-base overflow-clip transition-colors',
colors.outline,
colors.text,
colors.bg
)}
onmouseenter={() =>
flowGraphAssetsCtx && (flowGraphAssetsCtx.val.selectedAsset = data.asset)}
onmouseleave={() =>
flowGraphAssetsCtx && (flowGraphAssetsCtx.val.selectedAsset = undefined)}
>
<AssetGenericIcon
assetKind={data.asset.kind}
class="shrink-0 ml-1 mr-1.5 {isSelected ? 'text-accent' : 'text-tertiary'}"
size="16px"
/>
<span
class="text-3xs truncate flex-1 flex items-center gap-1 [mask-image:linear-gradient(to_right,black_85%,transparent)] mr-0.5"
>
{formatShortAssetPath(data.asset)}
<AssetColumnBadges
columns={assetColumns}
disableTooltip
disableWrap
badgeClasses="text-3xs transition-opacity opacity-50 {isSelected ? 'opacity-0' : ''}"
/>
</span>
{#if data.asset.kind === 'resource' && cachedResourceMetadata === undefined}
<Tooltip class={'pr-1 flex items-center justify-center'}>
<AlertTriangle size={16} class="text-orange-500" />
<svelte:fragment slot="text">Could not find resource</svelte:fragment>
</Tooltip>
{:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && !$userStore?.operator}
<div transition:slide={{ axis: 'x', duration: 100 }}>
<ExploreAssetButton
btnClasses="rounded-none"
asset={data.asset}
noText
buttonVariant="accent"
s3FilePicker={flowGraphAssetsCtx?.val.s3FilePicker}
dbManagerDrawer={globalDbManagerDrawer.val}
_resourceMetadata={cachedResourceMetadata}
/>
</div>
{/if}
</div>
<svelte:fragment slot="text">
{#if usageCount !== undefined}
Used in {pluralize(usageCount, 'step')}<br />
{/if}
<a
href={undefined}
class={twMerge(
'text-xs',
data.asset.kind === 'resource' ? 'text-accent cursor-pointer' : 'text-hint'
)}
onclick={() => {
if (data.asset.kind === 'resource')
flowGraphAssetsCtx?.val.resourceEditorDrawer?.initEdit(data.asset.path)
}}
>
{data.asset.path}
</a><br />
<span class="text-hint text-xs">
{formatAssetKind({ ...data.asset, metadata: cachedResourceMetadata })}</span
>
<AssetColumnBadges columns={assetColumns} disableTooltip />
</svelte:fragment>
</Tooltip>
{/snippet}
</NodeWrapper>