From 5909b352c813fdcd03f89e5efe0045c4ea99f17d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 20 Apr 2026 18:16:49 +0000 Subject: [PATCH] all --- .../src/lib/components/ScriptEditor.svelte | 438 ++++++++++-------- .../assets/AssetGraph/AssetGraphCanvas.svelte | 91 +++- .../AssetGraph/AssetGraphDetailsPane.svelte | 195 ++++++++ .../assets/AssetGraph/AssetGraphEdge.svelte | 41 ++ .../assets/AssetGraph/AssetNode.svelte | 54 +-- .../assets/AssetGraph/RunnableNode.svelte | 47 +- .../assets/AssetGraph/assetGraphLayout.ts | 19 +- .../lib/components/assets/AssetGraph/types.ts | 2 + frontend/src/lib/components/custom_ui.ts | 1 + .../components/scriptEditor/LogPanel.svelte | 14 +- .../(root)/(logged)/assets/graph/+page.svelte | 148 +++--- 11 files changed, 710 insertions(+), 340 deletions(-) create mode 100644 frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 4fa7a41844..ce43fc087b 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -153,6 +153,13 @@ modules?: { [key: string]: ScriptModule } | null editorBarRight?: import('svelte').Snippet enablePreprocessorSnippet?: boolean + /** + * Layout for the test/preview panel relative to the code editor. + * `right` (default) splits horizontally with the test panel on the + * right; `bottom` splits vertically with the test panel below — same + * orientation the flow editor uses for module step editing. + */ + previewLayout?: 'right' | 'bottom' } let { @@ -185,7 +192,8 @@ assets = $bindable(), modules = $bindable(undefined), editorBarRight, - enablePreprocessorSnippet = false + enablePreprocessorSnippet = false, + previewLayout = 'right' }: Props = $props() let initialArgs = structuredClone($state.snapshot(args)) @@ -1424,7 +1432,7 @@ - + {#if lang === 'ansible' && ansibleAlternativeExecutionMode != null} @@ -1495,208 +1503,247 @@ {:else} -
-
-
- {#if !(debugMode && isDebuggableScript)} -
-
- {#if testIsLoading} - - {:else} - {@const disableTriggerButton = - customUi?.previewPanel?.disableTriggerButton === true} - - {#if !disableTriggerButton} - + {#if previewLayout !== 'bottom'} +
+
+
+ {#if !(debugMode && isDebuggableScript)} +
+
+ {#if testIsLoading} + + {:else} + {@const disableTriggerButton = + customUi?.previewPanel?.disableTriggerButton === true} + + {#if !disableTriggerButton} + + {/if} {/if} +
+ {#if lastRecording} +
- {#if lastRecording} -
- - - {#if jsonView} -
- { - if (e.detail) { - if (activeModuleTab !== null) { - testPanelArgs = e.detail - } else { - args = e.detail - } - } - }} - updateOnBlur={false} - placeholder={`Write args as JSON.

Example:

{
  "foo": "12"
}`} - /> -
- {:else} -
-
- {#key argsRender} - {#if activeModuleTab !== null} - + + {#if previewLayout === 'bottom' && !(debugMode && isDebuggableScript)} +
+ {#if testIsLoading} + + {:else} + {/if}
-
- {/if} - - - - {#if scriptProgress && !debugMode} - - {/if} - {#snippet capturesTab()} -
- + { + if (e.detail) { + if (activeModuleTab !== null) { + testPanelArgs = e.detail + } else { + args = e.detail + } + } + }} + updateOnBlur={false} + placeholder={`Write args as JSON.

Example:

{
  "foo": "12"
}`} />
- {/snippet} - {#snippet customResultPanel()} - - {/snippet} -
-
- + {:else} +
+
+ {#key argsRender} + {#if activeModuleTab !== null} + + {:else} + + {/if} + {/key} + {#if showPsCommonParams} +
+ +
+ {/if} +
+
+ {/if} + + + + {#if scriptProgress && !debugMode} + + + {/if} + {#snippet capturesTab()} +
+ +
+ {/snippet} + {#snippet customResultPanel()} + + {/snippet} +
+
+ + {/key} {/if}
@@ -1901,13 +1948,12 @@ size="xs" onclick={toggleDebugMode} startIcon={{ icon: Bug }} + iconOnly btnClasses={debugMode ? '' : 'bg-surface hover:bg-surface-hover border border-tertiary/30'} - title="Toggle Debug Mode" - > - {debugMode ? 'Exit Debug' : 'Debug'} - + title={debugMode ? 'Exit Debug Mode' : 'Toggle Debug Mode'} + /> {/if} {#if showDebugPanel && !showDebugConsole} + {/if} + {#if selection.kind === 'runnable'} +
+
+ +
+ {#if selection.kind === 'asset'} +
+ Asset details. Use the producer/consumer arrows in the graph to navigate. +
+ {:else if selection.runnable_kind === 'flow'} +
+ Flows are not editable inline. Use the open-in-editor button above. +
+ {:else if scriptRes.loading && !script} +
+ + Loading script… +
+ {:else if scriptRes.error} +
+ Failed to load: {scriptRes.error.message} +
+ {:else if script} + {#key script.hash} + + {#snippet editorBarRight()} +
+ script?.tag, (v) => script && (script.tag = v)} /> +
+ {/snippet} +
+ {/key} + {/if} +
+
diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte new file mode 100644 index 0000000000..9abb17b867 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte @@ -0,0 +1,41 @@ + + + diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte index e4151f7cba..6d2b11ecdd 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte @@ -1,42 +1,38 @@ -
- -
- -
- {data.asset_kind} - {data.path} +
+
+ +
+ {data.asset_kind} + {formatShortAssetPath(asset)}
-
+ + + diff --git a/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte b/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte index 4a649429e0..f2db913452 100644 --- a/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte @@ -1,42 +1,35 @@ -
- -
- -
- {label} - {data.path} + + + + diff --git a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts index 6a9df0539e..ea0e518800 100644 --- a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts +++ b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts @@ -1,10 +1,15 @@ import { dagStratify, sugiyama, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag' import type { AssetGraphNodeData } from './types' +import { NODE } from '$lib/components/graph/util' -const NODE_WIDTH = 260 -const NODE_HEIGHT = 64 -const LAYER_GAP = 80 -const SIBLING_GAP = 32 +// Match the flow editor's per-node sizing and inter-node gaps so the asset +// graph's spacing feels familiar. NODE.height is the flow-step pill height (34 +// px) which is too tight for a 2-line asset card, so we add a bit of vertical +// breathing room. +const NODE_WIDTH = NODE.width +const NODE_HEIGHT = NODE.height + 30 +const LAYER_GAP = NODE.gap.vertical +const SIBLING_GAP = NODE.gap.horizontal interface GraphInput { nodes: Array<{ id: string; data: AssetGraphNodeData }> @@ -16,9 +21,9 @@ interface Positioned { 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). +// Sugiyama layered layout (top-down, same orientation as the flow editor — +// see compoundLayout.ts): producers above → assets in the middle → consumers +// below. Falls back to a stable grid if d3-dag throws (e.g., cyclic inputs). export function layoutAssetGraph(graph: GraphInput): Map { const byId = new Map() if (graph.nodes.length === 0) return byId diff --git a/frontend/src/lib/components/assets/AssetGraph/types.ts b/frontend/src/lib/components/assets/AssetGraph/types.ts index 70447eb85a..799cb7025d 100644 --- a/frontend/src/lib/components/assets/AssetGraph/types.ts +++ b/frontend/src/lib/components/assets/AssetGraph/types.ts @@ -37,3 +37,5 @@ export type AssetGraphNodeData = runnable_kind: GraphUsageKind path: string } + +export type AssetGraphSelection = AssetGraphNodeData diff --git a/frontend/src/lib/components/custom_ui.ts b/frontend/src/lib/components/custom_ui.ts index 5f017ee950..a5b0977d2d 100644 --- a/frontend/src/lib/components/custom_ui.ts +++ b/frontend/src/lib/components/custom_ui.ts @@ -56,6 +56,7 @@ export type PreviewPanelUi = { disableTracing?: boolean disableTriggerCaptures?: boolean disableTriggerButton?: boolean + disableJsonView?: boolean displayResult?: DisplayResultUi disableVariablePicker?: boolean disableDownload?: boolean diff --git a/frontend/src/lib/components/scriptEditor/LogPanel.svelte b/frontend/src/lib/components/scriptEditor/LogPanel.svelte index 82845c4a88..23274d9c35 100644 --- a/frontend/src/lib/components/scriptEditor/LogPanel.svelte +++ b/frontend/src/lib/components/scriptEditor/LogPanel.svelte @@ -106,6 +106,14 @@ let forceJson = $state(false) let isWac = $derived(!!previewJob?.workflow_as_code_status) + // Hide the tab strip when only the "Logs & Result" tab would render — + // avoids a single-item bar in embedded contexts (e.g. asset graph pane). + let visibleTabCount = $derived( + 1 + + (customUi?.disableHistory !== true ? 1 : 0) + + (showCaptures && customUi?.disableTriggerCaptures !== true ? 1 : 0) + + (customUi?.disableTracing !== true ? 1 : 0) + ) let wacDone = $derived( previewJob?.type == 'CompletedJob' || (previewJob != undefined && !previewIsLoading && !!previewJob.workflow_as_code_status) @@ -134,7 +142,11 @@
- + 1 ? 'flex-none' : 'hidden'} + > {#if customUi?.disableHistory !== true} diff --git a/frontend/src/routes/(root)/(logged)/assets/graph/+page.svelte b/frontend/src/routes/(root)/(logged)/assets/graph/+page.svelte index 72e32afdba..3593f6a656 100644 --- a/frontend/src/routes/(root)/(logged)/assets/graph/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/assets/graph/+page.svelte @@ -2,37 +2,58 @@ import { workspaceStore, userStore } from '$lib/stores' import { base } from '$lib/base' import Button from '$lib/components/common/button/Button.svelte' + import Toggle from '$lib/components/Toggle.svelte' import AssetGraphCanvas from '$lib/components/assets/AssetGraph/AssetGraphCanvas.svelte' - import type { AssetGraphResponse } from '$lib/components/assets/AssetGraph/types' + import AssetGraphDetailsPane from '$lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte' + import type { + AssetGraphResponse, + AssetGraphSelection + } from '$lib/components/assets/AssetGraph/types' import { ArrowLeft, Loader2, NetworkIcon, RefreshCw } from 'lucide-svelte' import { OpenAPI } from '$lib/gen' + import { resource } from 'runed' + import { Pane, Splitpanes } from 'svelte-splitpanes' - let loading = $state(true) - let error = $state(null) - let graph = $state(null) + // Variables and resources tend to be hubs (DB creds, API keys) used by + // most runnables, so they swamp the layout. Hidden by default; the + // toggle in the header opts back in. + const DATA_KINDS = ['s3object', 'ducklake', 'datatable', 'volume'] - async function load() { - if (!$workspaceStore) return - loading = true - error = null - try { + let includeConfigKinds = $state(false) + let selection = $state(undefined) + + let graphRes = resource( + [() => $workspaceStore, () => includeConfigKinds], + async ([ws, includeAll], _prev, { signal }) => { + if (!ws) return undefined const base_url = OpenAPI.BASE ?? '' - const res = await fetch(`${base_url}/api/w/${$workspaceStore}/assets/graph`, { - credentials: 'include' + const qs = includeAll ? '' : `?asset_kinds=${DATA_KINDS.join(',')}` + const res = await fetch(`${base_url}/w/${ws}/assets/graph${qs}`, { + credentials: 'include', + signal }) - 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 + if (!res.ok) throw new Error(`GET /assets/graph → ${res.status}`) + return (await res.json()) as AssetGraphResponse } + ) + + // Pluralize the kind label: 1 script, 2 scripts, 1 s3object, 2 s3objects. + function pluralize(n: number, singular: string): string { + return `${n} ${singular}${n === 1 ? '' : 's'}` } - $effect(() => { - if ($workspaceStore) load() + let summary = $derived.by(() => { + const g = graphRes.current + if (!g) return [] + const parts: string[] = [] + const scripts = g.runnables.filter((r) => r.usage_kind === 'script').length + const flows = g.runnables.filter((r) => r.usage_kind === 'flow').length + if (scripts) parts.push(pluralize(scripts, 'script')) + if (flows) parts.push(pluralize(flows, 'flow')) + const byKind = new Map() + for (const a of g.assets) byKind.set(a.kind, (byKind.get(a.kind) ?? 0) + 1) + for (const [kind, n] of byKind) parts.push(pluralize(n, kind)) + return parts }) @@ -43,62 +64,77 @@ {#if $userStore?.operator}
Page not available for operators.
{:else} -
+
-
+
-
- -

Asset graph

-
- - Workspace-wide view of assets and their producers/consumers. - -
-
- {#if graph} - - {graph.assets.length} assets · {graph.runnables.length} runnables · {graph.edges.length} - edges - + iconOnly + title="Back to assets" + /> + +

Asset graph

+ {#if summary.length > 0} + · {summary.join(' · ')} {/if} +
+
+ + onclick={() => graphRes.refetch()} + disabled={graphRes.loading} + iconOnly + title="Refresh" + />
-
- {#if loading} -
+
+ {#if graphRes.loading && !graphRes.current} +
Loading graph…
- {:else if error} -
- Failed to load graph: {error} + {:else if graphRes.error} +
+ Failed to load graph: {graphRes.error.message}
- {:else if graph && graph.assets.length === 0 && graph.runnables.length === 0} -
+ {:else if graphRes.current && graphRes.current.assets.length === 0 && graphRes.current.runnables.length === 0} +
No assets are referenced by scripts or flows in this workspace yet.
- {:else if graph} - + {:else if graphRes.current} + + + (selection = s)} + /> + + {#if selection && $workspaceStore} + + (selection = undefined)} + /> + + {/if} + {/if}