mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 08:07:03 +00:00
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:
co-authored by
Claude Opus 4.7
parent
e063db68c9
commit
dcbd77b796
+94
@@ -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"
|
||||
}
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user