This commit is contained in:
Ruben Fiszel
2026-04-20 18:16:49 +00:00
parent dcbd77b796
commit 5909b352c8
11 changed files with 710 additions and 340 deletions
+242 -196
View File
@@ -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 @@
</div>
</div>
<SplitPanesWrapper>
<Splitpanes class="!overflow-visible">
<Splitpanes class="!overflow-visible" horizontal={previewLayout === 'bottom'}>
<Pane bind:size={codePanelSize} minSize={10} class="!overflow-visible">
{#if lang === 'ansible' && ansibleAlternativeExecutionMode != null}
<!-- Vertical split for ansible with assets -->
@@ -1495,208 +1503,247 @@
<WacDiagram {code} language={lang ?? ''} />
</div>
{:else}
<div class="flex justify-center pt-1 relative">
<div class="absolute top-2 left-2">
<HideButton
hidden={false}
direction="right"
panelName="Test"
shortcut="U"
size="md"
on:click={() => {
toggleTestPanel()
}}
/>
</div>
{#if !(debugMode && isDebuggableScript)}
<div class="flex flex-row gap-2">
<div
class="flex flex-row divide-x divide-gray-800 dark:divide-gray-300 items-stretch"
>
{#if testIsLoading}
<Button on:click={jobLoader?.cancelJob} btnClasses="w-full" unifiedSize="md">
<WindmillIcon
white={true}
class="mr-2 text-white"
height="16px"
width="20px"
spin="fast"
/>
Cancel
</Button>
{:else}
{@const disableTriggerButton =
customUi?.previewPanel?.disableTriggerButton === true}
<Button
on:click={() => runTest()}
unifiedSize="md"
btnClasses="w-full {!disableTriggerButton ? 'rounded-r-none' : ''}"
variant="accent-secondary"
startIcon={{ icon: Play, classes: 'animate-none' }}
shortCut={{ Icon: CornerDownLeft }}
>
Test
</Button>
{#if !disableTriggerButton}
<CaptureButton on:openTriggers />
{#if previewLayout !== 'bottom'}
<div class="flex justify-center pt-1 relative">
<div class="absolute top-2 left-2">
<HideButton
hidden={false}
direction="right"
panelName="Test"
shortcut="U"
size="md"
on:click={() => {
toggleTestPanel()
}}
/>
</div>
{#if !(debugMode && isDebuggableScript)}
<div class="flex flex-row gap-2">
<div
class="flex flex-row divide-x divide-gray-800 dark:divide-gray-300 items-stretch"
>
{#if testIsLoading}
<Button on:click={jobLoader?.cancelJob} btnClasses="w-full" unifiedSize="md">
<WindmillIcon
white={true}
class="mr-2 text-white"
height="16px"
width="20px"
spin="fast"
/>
Cancel
</Button>
{:else}
{@const disableTriggerButton =
customUi?.previewPanel?.disableTriggerButton === true}
<Button
on:click={() => runTest()}
unifiedSize="md"
btnClasses="w-full {!disableTriggerButton ? 'rounded-r-none' : ''}"
variant="accent-secondary"
startIcon={{ icon: Play, classes: 'animate-none' }}
shortCut={{ Icon: CornerDownLeft }}
>
Test
</Button>
{#if !disableTriggerButton}
<CaptureButton on:openTriggers />
{/if}
{/if}
</div>
{#if lastRecording}
<Button
on:click={downloadRecording}
unifiedSize="md"
startIcon={{ icon: Download }}
iconOnly
title="Download recording"
/>
{/if}
</div>
{#if lastRecording}
<Button
on:click={downloadRecording}
unifiedSize="md"
startIcon={{ icon: Download }}
iconOnly
title="Download recording"
/>
{/if}
<div class="absolute top-2 right-2 flex items-center gap-2">
{#if customUi?.previewPanel?.disableJsonView !== true}
<Toggle size="2xs" bind:checked={jsonView} options={{ right: 'JSON' }} />
{/if}
<DropdownV2
size="xs"
items={[
{
displayName: 'Test & record',
icon: Disc,
action: () => recordAndTest()
}
]}
/>
</div>
{/if}
<div class="absolute top-2 right-2 flex items-center gap-2">
<Toggle size="2xs" bind:checked={jsonView} options={{ right: 'JSON' }} />
<DropdownV2
size="xs"
items={[
{
displayName: 'Test & record',
icon: Disc,
action: () => recordAndTest()
}
]}
/>
</div>
</div>
<Splitpanes
horizontal
class="!max-h-[calc(100%-{debugMode && isDebuggableScript ? '83' : '43'}px)]"
>
<Pane size={33}>
{#if jsonView}
<div
class="py-2"
style="height: {!schemaHeight || schemaHeight < 600 ? 600 : schemaHeight}px"
data-schema-picker
>
<JsonInputs
on:select={(e) => {
if (e.detail) {
if (activeModuleTab !== null) {
testPanelArgs = e.detail
} else {
args = e.detail
}
}
}}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
</div>
{:else}
<div class="px-4">
<div class="break-words relative font-sans" bind:clientHeight={schemaHeight}>
{#key argsRender}
{#if activeModuleTab !== null}
<SchemaForm
helperScript={{
source: 'inline',
code: editorCode,
//@ts-ignore
lang: effectiveLang
}}
compact
schema={testPanelSchema}
bind:args={testPanelArgs}
bind:isValid
noVariablePicker={customUi?.previewPanel?.disableVariablePicker === true}
showSchemaExplorer
{/if}
{#key previewLayout}
<Splitpanes
horizontal={previewLayout !== 'bottom'}
class="!max-h-[calc(100%-{debugMode && isDebuggableScript
? '83'
: previewLayout === 'bottom'
? '0'
: '43'}px)]"
>
<Pane size={previewLayout === 'bottom' ? 40 : 33}>
{#if previewLayout === 'bottom' && !(debugMode && isDebuggableScript)}
<div class="px-3 pt-2 pb-1 flex items-center gap-2">
{#if testIsLoading}
<Button on:click={jobLoader?.cancelJob} unifiedSize="sm" btnClasses="w-full">
<WindmillIcon
white={true}
class="mr-2 text-white"
height="14px"
width="16px"
spin="fast"
/>
{:else}
<SchemaForm
helperScript={{
source: 'inline',
code,
//@ts-ignore
lang
}}
compact
{schema}
bind:args
bind:isValid
noVariablePicker={customUi?.previewPanel?.disableVariablePicker === true}
showSchemaExplorer
/>
{/if}
{/key}
{#if showPsCommonParams}
<div class="mt-2">
<PowerShellCommonParams bind:args={psCommonParams} />
</div>
Cancel
</Button>
{:else}
<Button
on:click={() => runTest()}
unifiedSize="sm"
btnClasses="w-full"
variant="accent-secondary"
startIcon={{ icon: Play, classes: 'animate-none' }}
shortCut={{ Icon: CornerDownLeft }}
>
Test
</Button>
{/if}
</div>
</div>
{/if}
</Pane>
<Pane size={67} class="relative">
<LogPanel
bind:this={logPanel}
{lang}
previewJob={debugMode
? ({
id: 'debug',
logs: $debugState.logs,
result: $debugState.result,
success: !$debugState.error,
type: hasDebugResult ? 'CompletedJob' : 'QueuedJob'
} as any)
: testJob}
{pastPreviews}
previewIsLoading={debugMode
? $debugState.running && !$debugState.stopped
: testIsLoading}
{editor}
{diffEditor}
args={activeModuleTab !== null ? testPanelArgs : args}
{showCaptures}
customUi={customUi?.previewPanel}
showCustomResultPanel={showDebugPanel}
>
{#if scriptProgress && !debugMode}
<!-- Put to the slot in logpanel -->
<JobProgressBar
job={testJob}
{scriptProgress}
bind:this={jobProgressBar}
compact={true}
/>
{/if}
{#snippet capturesTab()}
<div class="h-full p-2">
<CaptureTable
bind:this={captureTable}
{hasPreprocessor}
canHavePreprocessor={canHavePreprocessor(lang)}
isFlow={false}
path={stablePathForCaptures}
canEdit={true}
on:applyArgs
on:updateSchema
on:addPreprocessor
{#if jsonView}
<div
class="py-2"
style="height: {!schemaHeight || schemaHeight < 600 ? 600 : schemaHeight}px"
data-schema-picker
>
<JsonInputs
on:select={(e) => {
if (e.detail) {
if (activeModuleTab !== null) {
testPanelArgs = e.detail
} else {
args = e.detail
}
}
}}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
</div>
{/snippet}
{#snippet customResultPanel()}
<DebugPanel
stackFrames={$debugState.stackFrames}
scopes={$debugState.scopes}
variables={$debugState.variables}
client={dapClient}
bind:selectedFrameId={selectedDebugFrameId}
/>
{/snippet}
</LogPanel>
</Pane>
</Splitpanes>
{:else}
<div class="px-4">
<div class="break-words relative font-sans" bind:clientHeight={schemaHeight}>
{#key argsRender}
{#if activeModuleTab !== null}
<SchemaForm
helperScript={{
source: 'inline',
code: editorCode,
//@ts-ignore
lang: effectiveLang
}}
compact
schema={testPanelSchema}
bind:args={testPanelArgs}
bind:isValid
noVariablePicker={customUi?.previewPanel?.disableVariablePicker ===
true}
showSchemaExplorer
/>
{:else}
<SchemaForm
helperScript={{
source: 'inline',
code,
//@ts-ignore
lang
}}
compact
{schema}
bind:args
bind:isValid
noVariablePicker={customUi?.previewPanel?.disableVariablePicker ===
true}
showSchemaExplorer
/>
{/if}
{/key}
{#if showPsCommonParams}
<div class="mt-2">
<PowerShellCommonParams bind:args={psCommonParams} />
</div>
{/if}
</div>
</div>
{/if}
</Pane>
<Pane size={previewLayout === 'bottom' ? 60 : 67} class="relative">
<LogPanel
bind:this={logPanel}
{lang}
previewJob={debugMode
? ({
id: 'debug',
logs: $debugState.logs,
result: $debugState.result,
success: !$debugState.error,
type: hasDebugResult ? 'CompletedJob' : 'QueuedJob'
} as any)
: testJob}
{pastPreviews}
previewIsLoading={debugMode
? $debugState.running && !$debugState.stopped
: testIsLoading}
{editor}
{diffEditor}
args={activeModuleTab !== null ? testPanelArgs : args}
{showCaptures}
customUi={customUi?.previewPanel}
showCustomResultPanel={showDebugPanel}
>
{#if scriptProgress && !debugMode}
<!-- Put to the slot in logpanel -->
<JobProgressBar
job={testJob}
{scriptProgress}
bind:this={jobProgressBar}
compact={true}
/>
{/if}
{#snippet capturesTab()}
<div class="h-full p-2">
<CaptureTable
bind:this={captureTable}
{hasPreprocessor}
canHavePreprocessor={canHavePreprocessor(lang)}
isFlow={false}
path={stablePathForCaptures}
canEdit={true}
on:applyArgs
on:updateSchema
on:addPreprocessor
/>
</div>
{/snippet}
{#snippet customResultPanel()}
<DebugPanel
stackFrames={$debugState.stackFrames}
scopes={$debugState.scopes}
variables={$debugState.variables}
client={dapClient}
bind:selectedFrameId={selectedDebugFrameId}
/>
{/snippet}
</LogPanel>
</Pane>
</Splitpanes>
{/key}
{/if}
</div>
</Pane>
@@ -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'}
</Button>
title={debugMode ? 'Exit Debug Mode' : 'Toggle Debug Mode'}
/>
{/if}
{#if showDebugPanel && !showDebugConsole}
<Button
@@ -5,35 +5,30 @@
Background,
Controls,
MiniMap,
ConnectionLineType,
type Node,
type Edge,
MarkerType
} from '@xyflow/svelte'
import AssetNode from './AssetNode.svelte'
import RunnableNode from './RunnableNode.svelte'
import AssetGraphEdge from './AssetGraphEdge.svelte'
import { layoutAssetGraph } from './assetGraphLayout'
import type { AssetGraphResponse } from './types'
import type { AssetGraphResponse, AssetGraphSelection } from './types'
interface Props {
graph: AssetGraphResponse
selection?: AssetGraphSelection | undefined
onselect?: (selection: AssetGraphSelection | undefined) => void
}
let { graph }: Props = $props()
let { graph, selection, onselect }: 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.
// Producers (w/rw) point runnable → asset; consumers (r) point asset →
// runnable. The sugiyama layout (assetGraphLayout.ts) renders this as a
// top-down DAG: producers above, assets in the middle, consumers below.
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
}> = []
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({
@@ -76,6 +71,13 @@
let model = $derived(build(graph))
let selectedId = $derived.by(() => {
if (!selection) return undefined
return selection.kind === 'asset'
? `asset:${selection.asset_kind}:${selection.path}`
: `${selection.runnable_kind}:${selection.path}`
})
let positionedNodes = $derived.by(() => {
const positions = layoutAssetGraph({
nodes: model.nodes.map((n) => ({ id: n.id, data: n.data })),
@@ -87,7 +89,8 @@
id: n.id,
type: n.type,
position: { x: p.x, y: p.y },
data: n.data
data: n.data,
selected: n.id === selectedId
}
})
})
@@ -97,7 +100,7 @@
id: e.id,
source: e.source,
target: e.target,
type: 'smoothstep',
type: 'asset',
animated: e.access === 'rw',
style:
e.access === 'w' || e.access === 'rw'
@@ -120,27 +123,67 @@
asset: AssetNode as any,
runnable: RunnableNode as any
}
const edgeTypes = {
asset: AssetGraphEdge as any
}
function handleNodeClick({ node }: { node: Node }) {
if (!onselect) return
const data = node.data as any
if (node.type === 'asset') {
onselect({ kind: 'asset', asset_kind: data.asset_kind, path: data.path })
} else {
onselect({ kind: 'runnable', runnable_kind: data.runnable_kind, path: data.path })
}
}
</script>
<div class="w-full h-full">
<div class="w-full h-full relative">
<SvelteFlow
{nodes}
{edges}
{nodeTypes}
{edgeTypes}
fitView
nodesDraggable
minZoom={0.2}
maxZoom={1.6}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable
zoomOnDoubleClick={false}
connectionLineType={ConnectionLineType.SmoothStep}
defaultEdgeOptions={{ type: 'asset' }}
proOptions={{ hideAttribution: true }}
onnodeclick={handleNodeClick}
onpaneclick={() => onselect?.(undefined)}
--background-color={false}
>
<div class="absolute inset-0 !bg-surface-secondary h-full -z-10"></div>
<Background />
<Controls />
<MiniMap pannable zoomable class="!bg-surface" />
<MiniMap
pannable
zoomable
class="!bg-surface"
nodeColor={(n) => (n.type === 'asset' ? 'rgb(96 165 250 / 0.5)' : 'rgb(52 211 153 / 0.5)')}
nodeStrokeColor="transparent"
maskColor="rgb(0 0 0 / 0.2)"
/>
</SvelteFlow>
</div>
<style>
:global(.svelte-flow) {
--xy-background-color: transparent;
<style lang="postcss">
:global(.svelte-flow__handle) {
opacity: 0;
}
:global(.svelte-flow__controls-button) {
@apply bg-surface border-0;
}
:global(.svelte-flow__controls-button:hover) {
@apply bg-surface-hover;
}
:global(.svelte-flow__node.selected .drop-shadow-sm) {
@apply outline outline-2 outline-blue-500;
}
</style>
@@ -0,0 +1,195 @@
<script lang="ts">
import { ScriptService, type Script } from '$lib/gen'
import { resource } from 'runed'
import { base } from '$lib/base'
import Button from '$lib/components/common/button/Button.svelte'
import ScriptEditor from '$lib/components/ScriptEditor.svelte'
import WorkerTagSelect from '$lib/components/WorkerTagSelect.svelte'
import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte'
import { formatAssetKind } from '$lib/components/assets/lib'
import { Code2, ExternalLink, GitBranch, Loader2, Save, X } from 'lucide-svelte'
import { inferArgs } from '$lib/infer'
import { emptySchema, sendUserToast } from '$lib/utils'
import type { AssetGraphSelection } from './types'
interface Props {
selection: AssetGraphSelection
workspace: string
onclose: () => void
}
let { selection, workspace, onclose }: Props = $props()
// Mirror the workspace-script editing flow from ScriptEditorDrawer: load
// the script by path, hand a mutable copy to <ScriptEditor>, and on save
// re-infer args then POST a new version with parent_hash chained.
let scriptRes = resource(
[() => workspace, () => selection],
async ([ws, sel], _prev, { signal }) => {
if (sel.kind !== 'runnable' || sel.runnable_kind !== 'script') return undefined
return await ScriptService.getScriptByPath({ workspace: ws, path: sel.path }, signal as any)
}
)
// Local mutable copy that ScriptEditor binds to. Reset whenever the
// underlying resource yields a new script (selection change or refetch).
let script = $state<Script | undefined>(undefined)
$effect.pre(() => {
const fresh = scriptRes.current
script = fresh ? structuredClone($state.snapshot(fresh) as Script) : undefined
})
let args = $state<Record<string, any>>({})
let saving = $state(false)
async function save() {
if (!script) return
saving = true
try {
script.schema = script.schema ?? emptySchema()
try {
const result = await inferArgs(script.language, script.content, script.schema)
;(script as any).auto_kind = result?.auto_kind || undefined
script.has_preprocessor = result?.has_preprocessor || undefined
} catch {
sendUserToast(`Could not parse code, are you sure it is valid?`, true)
}
await ScriptService.createScript({
workspace,
requestBody: {
...script,
language: script.language,
description: script.description ?? '',
parent_hash: script.hash != undefined ? String(script.hash) : undefined,
is_template: false,
tag: script.tag,
kind: script.kind as Script['kind'] | undefined,
lock: undefined
}
})
sendUserToast(`Saved ${script.path}`)
await scriptRes.refetch()
} catch (e: any) {
sendUserToast(`Save failed: ${e?.body ?? e?.message ?? e}`, true)
} finally {
saving = false
}
}
</script>
<div class="flex flex-col h-full bg-surface">
<div
class="flex items-center justify-between gap-2 px-3 py-2 border-b shrink-0 min-h-10 whitespace-nowrap"
>
<div class="flex items-center gap-2 min-w-0">
{#if selection.kind === 'asset'}
<AssetGenericIcon
assetKind={selection.asset_kind}
size="16px"
class="shrink-0 text-blue-600 dark:text-blue-400"
/>
<div class="flex flex-col min-w-0">
<span class="text-3xs uppercase tracking-wide text-tertiary">
{formatAssetKind({ kind: selection.asset_kind })}
</span>
<span class="text-xs font-mono truncate" title={selection.path}>{selection.path}</span>
</div>
{:else if selection.runnable_kind === 'script'}
<Code2 size={16} class="shrink-0 text-emerald-700 dark:text-emerald-400" />
<div class="flex flex-col min-w-0">
<span class="text-3xs uppercase tracking-wide text-tertiary">Script</span>
<span class="text-xs font-mono truncate" title={selection.path}>{selection.path}</span>
</div>
{:else}
<GitBranch size={16} class="shrink-0 text-emerald-700 dark:text-emerald-400" />
<div class="flex flex-col min-w-0">
<span class="text-3xs uppercase tracking-wide text-tertiary">Flow</span>
<span class="text-xs font-mono truncate" title={selection.path}>{selection.path}</span>
</div>
{/if}
</div>
<div class="flex items-center gap-1 shrink-0">
{#if selection.kind === 'runnable' && selection.runnable_kind === 'script' && script}
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: Save }}
onclick={save}
disabled={saving}
>
{saving ? 'Saving…' : 'Save'}
</Button>
{/if}
{#if selection.kind === 'runnable'}
<Button
variant="subtle"
unifiedSize="sm"
href={selection.runnable_kind === 'flow'
? `${base}/flows/edit/${selection.path}`
: `${base}/scripts/edit/${selection.path}`}
target="_blank"
startIcon={{ icon: ExternalLink }}
iconOnly
title="Open in full editor"
/>
{/if}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: X }}
onclick={onclose}
iconOnly
title="Close"
/>
</div>
</div>
<div class="flex-1 min-h-0 relative">
{#if selection.kind === 'asset'}
<div class="p-3 text-xs text-secondary">
Asset details. Use the producer/consumer arrows in the graph to navigate.
</div>
{:else if selection.runnable_kind === 'flow'}
<div class="p-3 text-xs text-secondary">
Flows are not editable inline. Use the open-in-editor button above.
</div>
{:else if scriptRes.loading && !script}
<div class="absolute inset-0 flex items-center justify-center gap-2 text-tertiary">
<Loader2 size={16} class="animate-spin" />
<span class="text-xs">Loading script…</span>
</div>
{:else if scriptRes.error}
<div class="p-3 text-xs text-red-500">
Failed to load: {scriptRes.error.message}
</div>
{:else if script}
{#key script.hash}
<ScriptEditor
showCaptures={false}
noSyncFromGithub
lang={script.language}
path={script.path}
tag={script.tag}
fixedOverflowWidgets={false}
previewLayout="bottom"
customUi={{
previewPanel: {
disableHistory: true,
disableTracing: true,
disableTriggerCaptures: true,
disableJsonView: true
}
}}
bind:code={script.content}
bind:schema={script.schema}
{args}
>
{#snippet editorBarRight()}
<div>
<WorkerTagSelect bind:tag={() => script?.tag, (v) => script && (script.tag = v)} />
</div>
{/snippet}
</ScriptEditor>
{/key}
{/if}
</div>
</div>
@@ -0,0 +1,41 @@
<script lang="ts">
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/svelte'
import { getStraightLinePath } from '$lib/components/graph/renderers/utils'
let {
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
markerEnd,
style
}: EdgeProps = $props()
// Mirror BaseEdge.svelte (flow editor): clamp the source-Y bend so long
// vertical jumps stay visually consistent, then append a straight segment.
let edgePath = $derived.by(() => {
const [bezier] = getBezierPath({
sourceX,
sourceY: targetY - sourceY > 100 ? targetY - 100 : sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
curvature: 0.25
})
return targetY - sourceY > 100
? `${bezier} ${getStraightLinePath({ sourceX, sourceY, targetY })}`
: bezier
})
</script>
<BaseEdge
path={edgePath}
{markerEnd}
{style}
interactionWidth={0}
label={undefined}
labelStyle={undefined}
/>
@@ -1,42 +1,38 @@
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import { Database, FileBox, Layers, HardDrive, KeyRound } from 'lucide-svelte'
import type { AssetKind } from '$lib/gen'
import { twMerge } from 'tailwind-merge'
import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte'
import { formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib'
import { NODE } from '$lib/components/graph/util'
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))
let asset = $derived({ kind: data.asset_kind, path: data.path })
</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 class="relative">
<div
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden',
'bg-surface-secondary outline outline-1 outline-transparent hover:outline-blue-400 transition-colors'
)}
style="width: {NODE.width}px; min-height: {NODE.height + 30}px;"
title={data.path}
>
<AssetGenericIcon
assetKind={data.asset_kind}
class="shrink-0 ml-2 mr-2 text-blue-600 dark:text-blue-400"
size="16px"
/>
<div class="flex flex-col min-w-0 flex-1 pr-2 py-1.5">
<span class="text-3xs uppercase tracking-wide text-tertiary truncate">{data.asset_kind}</span>
<span class="text-2xs font-mono text-emphasis truncate">{formatShortAssetPath(asset)}</span>
</div>
</div>
<Handle type="source" position={Position.Right} class="!bg-blue-500" />
</div>
<Handle type="target" position={Position.Top} isConnectable={false} />
<Handle type="source" position={Position.Bottom} isConnectable={false} />
@@ -1,42 +1,35 @@
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import { Code2, GitBranch, ExternalLink } from 'lucide-svelte'
import { base } from '$lib/base'
import { Code2, GitBranch } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import type { GraphUsageKind } from './types'
import { NODE } from '$lib/components/graph/util'
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')
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 class="relative">
<div
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden',
'bg-surface-tertiary outline outline-1 outline-transparent hover:outline-emerald-500 transition-colors'
)}
style="width: {NODE.width}px; min-height: {NODE.height + 30}px;"
title={data.path}
>
<Icon size={16} class="shrink-0 ml-2 mr-2 text-emerald-700 dark:text-emerald-400" />
<div class="flex flex-col min-w-0 flex-1 pr-2 py-1.5">
<span class="text-3xs uppercase tracking-wide text-tertiary truncate">{label}</span>
<span class="text-2xs font-mono text-emphasis truncate">{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>
<Handle type="target" position={Position.Top} isConnectable={false} />
<Handle type="source" position={Position.Bottom} isConnectable={false} />
@@ -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<string, Positioned> {
const byId = new Map<string, Positioned>()
if (graph.nodes.length === 0) return byId
@@ -37,3 +37,5 @@ export type AssetGraphNodeData =
runnable_kind: GraphUsageKind
path: string
}
export type AssetGraphSelection = AssetGraphNodeData
+1
View File
@@ -56,6 +56,7 @@ export type PreviewPanelUi = {
disableTracing?: boolean
disableTriggerCaptures?: boolean
disableTriggerButton?: boolean
disableJsonView?: boolean
displayResult?: DisplayResultUi
disableVariablePicker?: boolean
disableDownload?: boolean
@@ -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 @@
</DrawerContent>
</Drawer>
<div class="h-full flex flex-col">
<Tabs bind:selected={selectedTab} class="pt-1" wrapperClass="flex-none">
<Tabs
bind:selected={selectedTab}
class="pt-1"
wrapperClass={visibleTabCount > 1 ? 'flex-none' : 'hidden'}
>
<Tab value="logs" label="Logs & Result" />
{#if customUi?.disableHistory !== true}
<Tab value="history" label="History" />
@@ -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<string | null>(null)
let graph = $state<AssetGraphResponse | null>(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<AssetGraphSelection | undefined>(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<string[]>(() => {
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<string, number>()
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
})
</script>
@@ -43,62 +64,77 @@
{#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 flex-col h-full">
<div
class="flex items-center justify-between gap-4 px-4 py-2 border-b border-gray-200 dark:border-gray-800"
class="border-b flex flex-row justify-between gap-2 px-2 py-1 items-center overflow-y-visible overflow-x-auto min-h-10 shrink-0 whitespace-nowrap"
>
<div class="flex items-center gap-3">
<div class="flex flex-row items-center gap-2">
<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>
iconOnly
title="Back to assets"
/>
<NetworkIcon size={16} class="text-tertiary shrink-0" />
<h1 class="text-sm font-semibold">Asset graph</h1>
{#if summary.length > 0}
<span class="text-xs text-tertiary">· {summary.join(' · ')}</span>
{/if}
</div>
<div class="flex flex-row items-center gap-2">
<Toggle
bind:checked={includeConfigKinds}
size="xs"
options={{ right: 'Vars & resources' }}
disabled={graphRes.loading}
/>
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: RefreshCw }}
onclick={load}
disabled={loading}
>
Refresh
</Button>
onclick={() => graphRes.refetch()}
disabled={graphRes.loading}
iconOnly
title="Refresh"
/>
</div>
</div>
<div class="flex-1 relative">
{#if loading}
<div class="absolute inset-0 flex items-center justify-center gap-2 text-tertiary">
<div class="flex-1 min-h-0">
{#if graphRes.loading && !graphRes.current}
<div class="h-full 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}
{:else if graphRes.error}
<div class="h-full flex items-center justify-center text-red-500 text-sm">
Failed to load graph: {graphRes.error.message}
</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">
{:else if graphRes.current && graphRes.current.assets.length === 0 && graphRes.current.runnables.length === 0}
<div class="h-full 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} />
{:else if graphRes.current}
<Splitpanes class="!h-full">
<Pane size={selection ? 60 : 100}>
<AssetGraphCanvas
graph={graphRes.current}
{selection}
onselect={(s) => (selection = s)}
/>
</Pane>
{#if selection && $workspaceStore}
<Pane size={40} minSize={25}>
<AssetGraphDetailsPane
{selection}
workspace={$workspaceStore}
onclose={() => (selection = undefined)}
/>
</Pane>
{/if}
</Splitpanes>
{/if}
</div>
</div>