mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 08:01:35 +00:00
all
This commit is contained in:
@@ -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/> "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/> "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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user