feat: add workspace asset graph view

Workspace-wide canvas of assets and their producer/consumer scripts,
reachable from the assets page. Left-to-right layered layout via
d3-dag sugiyama, rendered with @xyflow/svelte (same stack as the
flow editor). GET /w/:ws/assets/graph returns deduped nodes + edges.

Follow-ups: filters (kind/folder/search), node detail drawer, inline
script edit from a clicked node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-04-19 03:10:09 +00:00
co-authored by Claude Opus 4.7
parent e063db68c9
commit dcbd77b796
10 changed files with 683 additions and 2 deletions
@@ -0,0 +1,94 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n asset.kind AS \"asset_kind!: AssetKind\",\n asset.path AS \"asset_path!\",\n asset.usage_kind AS \"usage_kind!: AssetUsageKind\",\n asset.usage_path AS \"usage_path!\",\n asset.usage_access_type::text AS \"access_type\"\n FROM asset\n WHERE asset.workspace_id = $1\n AND asset.usage_kind IN ('script', 'flow')\n AND ($2::asset_kind[] IS NULL OR asset.kind = ANY($2))\n AND ($3::text IS NULL OR asset.usage_path LIKE $3)\n GROUP BY asset.kind, asset.path, asset.usage_kind, asset.usage_path, asset.usage_access_type\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "asset_kind!: AssetKind",
"type_info": {
"Custom": {
"name": "asset_kind",
"kind": {
"Enum": [
"s3object",
"resource",
"variable",
"ducklake",
"datatable",
"volume"
]
}
}
}
},
{
"ordinal": 1,
"name": "asset_path!",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "usage_kind!: AssetUsageKind",
"type_info": {
"Custom": {
"name": "asset_usage_kind",
"kind": {
"Enum": [
"script",
"flow",
"job"
]
}
}
}
},
{
"ordinal": 3,
"name": "usage_path!",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "access_type",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "asset_kind[]",
"kind": {
"Array": {
"Custom": {
"name": "asset_kind",
"kind": {
"Enum": [
"s3object",
"resource",
"variable",
"ducklake",
"datatable",
"volume"
]
}
}
}
}
}
},
"Text"
]
},
"nullable": [
false,
false,
false,
false,
null
]
},
"hash": "5ae004333c20e6f7c28025f16ad87562dbdac633f3248ac228e00b7c8b49b800"
}
+119
View File
@@ -20,6 +20,7 @@ pub fn workspaced_service() -> Router {
.route("/list", get(list_assets))
.route("/list_by_usages", post(list_assets_by_usages))
.route("/list_favorites", get(list_favorites))
.route("/graph", get(asset_graph))
}
#[derive(Deserialize)]
@@ -363,3 +364,121 @@ async fn list_favorites(
Ok(Json(favorites))
}
// ------------------------------------------------------------------
// GET /w/:workspace/assets/graph
// ------------------------------------------------------------------
// Workspace-wide asset ↔ runnable graph. One row per unique
// (asset_kind, asset_path, usage_kind, usage_path, access_type) — the
// frontend aggregates into nodes and edges.
#[derive(Deserialize)]
struct GraphQuery {
pub asset_kinds: Option<String>,
pub folder: Option<String>,
}
#[derive(Serialize, Debug)]
struct GraphAssetNode {
kind: AssetKind,
path: String,
}
#[derive(Serialize, Debug)]
struct GraphRunnableNode {
path: String,
usage_kind: AssetUsageKind,
}
#[derive(Serialize, Debug)]
struct GraphEdge {
runnable_path: String,
runnable_kind: AssetUsageKind,
asset_kind: AssetKind,
asset_path: String,
access_type: Option<String>,
}
#[derive(Serialize, Debug)]
struct AssetGraphResponse {
assets: Vec<GraphAssetNode>,
runnables: Vec<GraphRunnableNode>,
edges: Vec<GraphEdge>,
}
async fn asset_graph(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
Query(q): Query<GraphQuery>,
) -> JsonResult<AssetGraphResponse> {
let mut tx = user_db.begin(&authed).await?;
let kind_filter: Option<Vec<AssetKind>> = q.asset_kinds.as_ref().map(|s| {
s.split(',')
.filter_map(|k| {
serde_json::from_value::<AssetKind>(Value::String(k.trim().into())).ok()
})
.collect()
});
let kind_filter_ref = kind_filter.as_deref();
let folder_filter = q.folder.as_deref().map(|f| format!("f/{}/%", f));
// One row per (asset_kind, asset_path, usage_kind, usage_path, access_type).
// The `usage_kind IN ('script','flow')` clause excludes `job`-kind usage rows
// (runtime-detected, ephemeral) so the graph stays stable.
let rows = sqlx::query!(
r#"
SELECT
asset.kind AS "asset_kind!: AssetKind",
asset.path AS "asset_path!",
asset.usage_kind AS "usage_kind!: AssetUsageKind",
asset.usage_path AS "usage_path!",
asset.usage_access_type::text AS "access_type"
FROM asset
WHERE asset.workspace_id = $1
AND asset.usage_kind IN ('script', 'flow')
AND ($2::asset_kind[] IS NULL OR asset.kind = ANY($2))
AND ($3::text IS NULL OR asset.usage_path LIKE $3)
GROUP BY asset.kind, asset.path, asset.usage_kind, asset.usage_path, asset.usage_access_type
"#,
&w_id,
kind_filter_ref as Option<&[AssetKind]>,
folder_filter.as_deref(),
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
let mut edges = Vec::with_capacity(rows.len());
let mut asset_set: std::collections::HashSet<(AssetKind, String)> = Default::default();
let mut runnable_set: std::collections::HashSet<(AssetUsageKind, String)> = Default::default();
for r in rows {
asset_set.insert((r.asset_kind, r.asset_path.clone()));
runnable_set.insert((r.usage_kind, r.usage_path.clone()));
edges.push(GraphEdge {
runnable_path: r.usage_path,
runnable_kind: r.usage_kind,
asset_kind: r.asset_kind,
asset_path: r.asset_path,
access_type: r.access_type,
});
}
let mut assets: Vec<GraphAssetNode> = asset_set
.into_iter()
.map(|(kind, path)| GraphAssetNode { kind, path })
.collect();
assets.sort_by(|a, b| a.path.cmp(&b.path));
let mut runnables: Vec<GraphRunnableNode> = runnable_set
.into_iter()
.map(|(usage_kind, path)| GraphRunnableNode { path, usage_kind })
.collect();
runnables.sort_by(|a, b| a.path.cmp(&b.path));
Ok(Json(AssetGraphResponse { assets, runnables, edges }))
}
@@ -0,0 +1,146 @@
<script lang="ts">
import '@xyflow/svelte/dist/base.css'
import {
SvelteFlow,
Background,
Controls,
MiniMap,
type Node,
type Edge,
MarkerType
} from '@xyflow/svelte'
import AssetNode from './AssetNode.svelte'
import RunnableNode from './RunnableNode.svelte'
import { layoutAssetGraph } from './assetGraphLayout'
import type { AssetGraphResponse } from './types'
interface Props {
graph: AssetGraphResponse
}
let { graph }: Props = $props()
// Build the node/edge model. Producers (w/rw) point runnable → asset;
// consumers (r) point asset → runnable. This gives a left-to-right DAG
// where assets sit between their producers and consumers.
function build(g: AssetGraphResponse) {
const nodes: Array<{
id: string
type: 'asset' | 'runnable'
data: any
}> = []
const edges: Array<{
id: string
source: string
target: string
access: string | null
}> = []
for (const a of g.assets) {
nodes.push({
id: `asset:${a.kind}:${a.path}`,
type: 'asset',
data: { asset_kind: a.kind, path: a.path }
})
}
for (const r of g.runnables) {
nodes.push({
id: `${r.usage_kind}:${r.path}`,
type: 'runnable',
data: { runnable_kind: r.usage_kind, path: r.path }
})
}
for (const e of g.edges) {
const runnableId = `${e.runnable_kind}:${e.runnable_path}`
const assetId = `asset:${e.asset_kind}:${e.asset_path}`
const access = e.access_type ?? 'r'
if (access === 'w' || access === 'rw') {
edges.push({
id: `prod:${runnableId}->${assetId}`,
source: runnableId,
target: assetId,
access
})
}
if (access === 'r' || access === 'rw') {
edges.push({
id: `cons:${assetId}->${runnableId}`,
source: assetId,
target: runnableId,
access
})
}
}
return { nodes, edges }
}
let model = $derived(build(graph))
let positionedNodes = $derived.by(() => {
const positions = layoutAssetGraph({
nodes: model.nodes.map((n) => ({ id: n.id, data: n.data })),
edges: model.edges.map((e) => ({ source: e.source, target: e.target }))
})
return model.nodes.map<Node>((n) => {
const p = positions.get(n.id) ?? { x: 0, y: 0 }
return {
id: n.id,
type: n.type,
position: { x: p.x, y: p.y },
data: n.data
}
})
})
let flowEdges = $derived.by(() =>
model.edges.map<Edge>((e) => ({
id: e.id,
source: e.source,
target: e.target,
type: 'smoothstep',
animated: e.access === 'rw',
style:
e.access === 'w' || e.access === 'rw'
? 'stroke: rgb(59 130 246); stroke-width: 1.5px;'
: 'stroke: rgb(107 114 128); stroke-width: 1.25px;',
markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14 }
}))
)
let nodes = $state.raw<Node[]>([])
let edges = $state.raw<Edge[]>([])
$effect(() => {
nodes = positionedNodes
})
$effect(() => {
edges = flowEdges
})
const nodeTypes = {
asset: AssetNode as any,
runnable: RunnableNode as any
}
</script>
<div class="w-full h-full">
<SvelteFlow
{nodes}
{edges}
{nodeTypes}
fitView
nodesDraggable
nodesConnectable={false}
elementsSelectable
proOptions={{ hideAttribution: true }}
>
<Background />
<Controls />
<MiniMap pannable zoomable class="!bg-surface" />
</SvelteFlow>
</div>
<style>
:global(.svelte-flow) {
--xy-background-color: transparent;
}
</style>
@@ -0,0 +1,42 @@
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import { Database, FileBox, Layers, HardDrive, KeyRound } from 'lucide-svelte'
import type { AssetKind } from '$lib/gen'
interface Props {
data: { asset_kind: AssetKind; path: string }
}
let { data }: Props = $props()
function iconFor(kind: AssetKind) {
switch (kind) {
case 's3object':
return FileBox
case 'resource':
return KeyRound
case 'ducklake':
return Database
case 'datatable':
return Layers
case 'volume':
return HardDrive
default:
return FileBox
}
}
let Icon = $derived(iconFor(data.asset_kind))
</script>
<div
class="bg-surface border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 w-[260px] hover:border-blue-400 transition-colors"
>
<Handle type="target" position={Position.Left} class="!bg-blue-500" />
<div class="flex items-center gap-2">
<Icon size={16} class="text-blue-600 dark:text-blue-400 shrink-0" />
<div class="flex flex-col min-w-0 flex-1">
<span class="text-[10px] uppercase tracking-wide text-tertiary">{data.asset_kind}</span>
<span class="text-xs font-mono truncate" title={data.path}>{data.path}</span>
</div>
</div>
<Handle type="source" position={Position.Right} class="!bg-blue-500" />
</div>
@@ -0,0 +1,42 @@
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import { Code2, GitBranch, ExternalLink } from 'lucide-svelte'
import { base } from '$lib/base'
import type { GraphUsageKind } from './types'
interface Props {
data: { runnable_kind: GraphUsageKind; path: string }
}
let { data }: Props = $props()
let href = $derived(
data.runnable_kind === 'flow'
? `${base}/flows/edit/${data.path}`
: `${base}/scripts/edit/${data.path}`
)
let Icon = $derived(data.runnable_kind === 'flow' ? GitBranch : Code2)
let label = $derived(data.runnable_kind === 'flow' ? 'FLOW' : 'SCRIPT')
</script>
<div
class="bg-surface-secondary border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 w-[260px] hover:border-emerald-500 transition-colors"
>
<Handle type="target" position={Position.Left} class="!bg-emerald-500" />
<div class="flex items-center gap-2">
<Icon size={16} class="text-emerald-700 dark:text-emerald-400 shrink-0" />
<div class="flex flex-col min-w-0 flex-1">
<span class="text-[10px] uppercase tracking-wide text-tertiary">{label}</span>
<span class="text-xs font-mono truncate" title={data.path}>{data.path}</span>
</div>
<a
{href}
target="_blank"
class="text-tertiary hover:text-primary"
onclick={(e) => e.stopPropagation()}
aria-label="Open {label.toLowerCase()} editor"
>
<ExternalLink size={14} />
</a>
</div>
<Handle type="source" position={Position.Right} class="!bg-emerald-500" />
</div>
@@ -0,0 +1,77 @@
import { dagStratify, sugiyama, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag'
import type { AssetGraphNodeData } from './types'
const NODE_WIDTH = 260
const NODE_HEIGHT = 64
const LAYER_GAP = 80
const SIBLING_GAP = 32
interface GraphInput {
nodes: Array<{ id: string; data: AssetGraphNodeData }>
edges: Array<{ source: string; target: string }>
}
interface Positioned {
x: number
y: number
}
// Sugiyama layered layout: producers left → assets middle → consumers right.
// Falls back to a stable grid if d3-dag throws (e.g., on cyclic inputs —
// shouldn't happen in practice for asset usage).
export function layoutAssetGraph(graph: GraphInput): Map<string, Positioned> {
const byId = new Map<string, Positioned>()
if (graph.nodes.length === 0) return byId
const parentsByChild = new Map<string, string[]>()
for (const n of graph.nodes) parentsByChild.set(n.id, [])
for (const e of graph.edges) {
const arr = parentsByChild.get(e.target)
if (arr && arr.indexOf(e.source) === -1) arr.push(e.source)
}
try {
const dagNodes = graph.nodes.map((n) => ({
id: n.id,
parentIds: parentsByChild.get(n.id) ?? []
}))
const dag = dagStratify().id(({ id }: { id: string }) => id)(dagNodes)
const layout = sugiyama()
.decross(graph.nodes.length > 30 ? decrossTwoLayer() : decrossOpt())
.coord(coordCenter())
.nodeSize(
() => [NODE_WIDTH + SIBLING_GAP, NODE_HEIGHT + LAYER_GAP] as readonly [number, number]
)
layout(dag as any)
for (const desc of dag.descendants()) {
const id = (desc as any).data.id as string
byId.set(id, {
x: (desc as any).x ?? 0,
y: ((desc as any).y ?? 0) - (NODE_HEIGHT + LAYER_GAP) / 2
})
}
// Normalize so min x,y = 0
let minX = Infinity
let minY = Infinity
for (const p of byId.values()) {
if (p.x < minX) minX = p.x
if (p.y < minY) minY = p.y
}
if (isFinite(minX) && isFinite(minY)) {
for (const p of byId.values()) {
p.x -= minX
p.y -= minY
}
}
return byId
} catch {
const cols = Math.max(1, Math.ceil(Math.sqrt(graph.nodes.length)))
graph.nodes.forEach((n, i) => {
byId.set(n.id, {
x: (i % cols) * (NODE_WIDTH + SIBLING_GAP),
y: Math.floor(i / cols) * (NODE_HEIGHT + LAYER_GAP)
})
})
return byId
}
}
@@ -0,0 +1,39 @@
import type { AssetKind } from '$lib/gen'
export type GraphUsageKind = 'script' | 'flow'
export interface AssetGraphAssetNode {
kind: AssetKind
path: string
}
export interface AssetGraphRunnableNode {
path: string
usage_kind: GraphUsageKind
}
export interface AssetGraphEdge {
runnable_path: string
runnable_kind: GraphUsageKind
asset_kind: AssetKind
asset_path: string
access_type: 'r' | 'w' | 'rw' | null
}
export interface AssetGraphResponse {
assets: AssetGraphAssetNode[]
runnables: AssetGraphRunnableNode[]
edges: AssetGraphEdge[]
}
export type AssetGraphNodeData =
| {
kind: 'asset'
asset_kind: AssetKind
path: string
}
| {
kind: 'runnable'
runnable_kind: GraphUsageKind
path: string
}
@@ -34,7 +34,8 @@
import { untrack } from 'svelte'
import { VolumeService } from '$lib/gen'
import VolumesDrawer from '$lib/components/assets/VolumesDrawer.svelte'
import { HardDriveIcon } from 'lucide-svelte'
import { HardDriveIcon, NetworkIcon } from 'lucide-svelte'
import { base } from '$lib/base'
interface AssetCursor {
created_at?: string
@@ -155,7 +156,18 @@
title="Assets"
tooltip="Assets show up here whenever you use them in Windmill."
documentationLink="https://www.windmill.dev/docs/core_concepts/assets"
/>
>
<div class="flex justify-end">
<Button
variant="accent-secondary"
unifiedSize="sm"
href="{base}/assets/graph"
startIcon={{ icon: NetworkIcon }}
>
Graph view
</Button>
</div>
</PageHeader>
<Section label="All workspace assets" class="mb-20">
<div class="flex gap-4">
@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'Asset Graph' }
}
}
@@ -0,0 +1,105 @@
<script lang="ts">
import { workspaceStore, userStore } from '$lib/stores'
import { base } from '$lib/base'
import Button from '$lib/components/common/button/Button.svelte'
import AssetGraphCanvas from '$lib/components/assets/AssetGraph/AssetGraphCanvas.svelte'
import type { AssetGraphResponse } from '$lib/components/assets/AssetGraph/types'
import { ArrowLeft, Loader2, NetworkIcon, RefreshCw } from 'lucide-svelte'
import { OpenAPI } from '$lib/gen'
let loading = $state(true)
let error = $state<string | null>(null)
let graph = $state<AssetGraphResponse | null>(null)
async function load() {
if (!$workspaceStore) return
loading = true
error = null
try {
const base_url = OpenAPI.BASE ?? ''
const res = await fetch(`${base_url}/api/w/${$workspaceStore}/assets/graph`, {
credentials: 'include'
})
if (!res.ok) {
throw new Error(`GET /assets/graph → ${res.status}`)
}
graph = (await res.json()) as AssetGraphResponse
} catch (e) {
error = e instanceof Error ? e.message : String(e)
} finally {
loading = false
}
}
$effect(() => {
if ($workspaceStore) load()
})
</script>
<svelte:head>
<title>Asset graph — Windmill</title>
</svelte:head>
{#if $userStore?.operator}
<div class="p-8 text-tertiary">Page not available for operators.</div>
{:else}
<div class="flex flex-col h-[calc(100vh-3rem)]">
<div
class="flex items-center justify-between gap-4 px-4 py-2 border-b border-gray-200 dark:border-gray-800"
>
<div class="flex items-center gap-3">
<Button
variant="subtle"
unifiedSize="sm"
href="{base}/assets"
startIcon={{ icon: ArrowLeft }}
>
Assets
</Button>
<div class="flex items-center gap-2">
<NetworkIcon size={18} class="text-tertiary" />
<h1 class="text-lg font-semibold">Asset graph</h1>
</div>
<span class="text-xs text-tertiary">
Workspace-wide view of assets and their producers/consumers.
</span>
</div>
<div class="flex items-center gap-2">
{#if graph}
<span class="text-xs text-tertiary">
{graph.assets.length} assets · {graph.runnables.length} runnables · {graph.edges.length}
edges
</span>
{/if}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: RefreshCw }}
onclick={load}
disabled={loading}
>
Refresh
</Button>
</div>
</div>
<div class="flex-1 relative">
{#if loading}
<div class="absolute inset-0 flex items-center justify-center gap-2 text-tertiary">
<Loader2 size={18} class="animate-spin" />
<span>Loading graph…</span>
</div>
{:else if error}
<div class="absolute inset-0 flex items-center justify-center text-red-500 text-sm">
Failed to load graph: {error}
</div>
{:else if graph && graph.assets.length === 0 && graph.runnables.length === 0}
<div class="absolute inset-0 flex items-center justify-center text-tertiary text-sm">
No assets are referenced by scripts or flows in this workspace yet.
</div>
{:else if graph}
<AssetGraphCanvas {graph} />
{/if}
</div>
</div>
{/if}