fix(frontend): flow log viewer add keyboard navigation (#6523)

* prevent navigation in the flow when the preview is open

* keyboardNav with registration link

* Improve keyboard nav UI

* Focus log with keyboard nav

* Add native focus

* initialize focused on key down

* fix log expand not sticking

* Improve tree structure

* nit

* nit

* Update active section on click

* add tooltip for keyboard navigation
This commit is contained in:
Guilhem
2025-09-03 18:27:08 +00:00
committed by GitHub
parent db95c9d65c
commit d400fe76c0
6 changed files with 720 additions and 458 deletions
@@ -682,7 +682,7 @@
}
break
case 'ArrowDown': {
if (!$insertButtonOpen) {
if (!$insertButtonOpen && !flowPreviewButtons?.getPreviewOpen()) {
let ids = generateIds()
let idx = ids.indexOf($selectedIdStore)
if (idx > -1 && idx < ids.length - 1) {
@@ -693,7 +693,7 @@
break
}
case 'ArrowUp': {
if (!$insertButtonOpen) {
if (!$insertButtonOpen && !flowPreviewButtons?.getPreviewOpen()) {
let ids = generateIds()
let idx = ids.indexOf($selectedIdStore)
if (idx > 0 && idx < ids.length) {
@@ -0,0 +1,92 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import { ChevronDown, ChevronRight } from 'lucide-svelte'
interface Props {
id: string
isCollapsible: boolean
isRunning?: boolean
isCurrent: (id: string) => boolean
isExpanded: (id: string, isRunning?: boolean) => boolean
toggleExpanded: (id: string) => void
children: import('svelte').Snippet
label: import('svelte').Snippet
class?: string
select: (id: string) => void
}
let {
id,
isCollapsible,
isRunning,
isCurrent,
isExpanded,
toggleExpanded,
children,
label,
class: className = '',
select
}: Props = $props()
function handleClick() {
if (isCollapsible) {
toggleExpanded(id)
}
select(id)
}
</script>
<li
class={twMerge(
'border-b flex flex-col',
isCurrent(id) ? 'border-l-2 !border-l-gray-400 -ml-[2px]' : ''
)}
>
<button
class={twMerge(
'py-1 leading-tight w-full flex items-center justify-left text-xs text-tertiary hover:text-primary ',
isCurrent(id) ? 'bg-surface-hover text-primary' : '',
className
)}
tabindex="-1"
onclick={handleClick}
data-nav-id={id}
>
<div class="px-1">
{#if isCollapsible}
{#if isExpanded(id, isRunning) || id === 'root-flow'}
<ChevronDown size={8} strokeWidth={isCurrent(id) ? 4 : 2} />
{:else}
<ChevronRight size={8} strokeWidth={isCurrent(id) ? 4 : 2} />
{/if}
{:else}
<!-- Empty subflow - no collapse button, just spacing -->
<div class="w-2"></div>
{/if}
</div>
<div class="w-full leading-tight">
<div class={twMerge('w-full pr-2 cursor-pointer', isCurrent(id) ? 'bg-surface-hover' : '')}>
{@render label()}
</div>
</div>
</button>
{#if isExpanded(id) || !isCollapsible}
<div class="my-1 transition-all duration-200 ease-in-out">
<div class="pl-4">
{@render children()}
</div>
</div>
{/if}
</li>
<style>
.transition-all {
transition: all 0.2s ease-in-out;
}
[data-nav-id]:focus-visible {
outline: none;
outline-offset: 0;
}
</style>
+445 -383
View File
@@ -1,18 +1,17 @@
<script lang="ts">
import {
ChevronDown,
ChevronRight,
GitBranch,
Repeat,
Code,
ArrowDownToLine,
ArrowDownFromLine,
FoldVertical,
UnfoldVertical
UnfoldVertical,
ExternalLink,
Keyboard
} from 'lucide-svelte'
import { base } from '$lib/base'
import { workspaceStore } from '$lib/stores'
import { truncateRev } from '$lib/utils'
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import LogViewer from './LogViewer.svelte'
import FlowLogViewer from './FlowLogViewer.svelte'
@@ -21,6 +20,10 @@
import FlowJobsMenu from './flows/map/FlowJobsMenu.svelte'
import BarsStaggered from './icons/BarsStaggered.svelte'
import type { GraphModuleState } from './graph/model'
import type { NavigationChain } from '$lib/keyboardChain'
import { updateLinks } from '$lib/keyboardChain'
import FlowLogRow from './FlowLogRow.svelte'
import { Tooltip } from './meltComponents'
type RootJobData = Partial<Job>
@@ -46,6 +49,9 @@
getSelectedIteration: (stepId: string) => number
flowSummary?: string
mode?: 'flow' | 'aiagent'
currentId?: string | null
navigationChain?: NavigationChain
select: (id: string) => void
}
let {
@@ -65,7 +71,10 @@
onSelectedIteration,
getSelectedIteration,
flowSummary,
mode = 'flow'
mode = 'flow',
currentId,
navigationChain = $bindable(),
select
}: Props = $props()
function getJobLink(jobId: string | undefined): string {
@@ -210,6 +219,106 @@
}
})
let subloopNavigationChains = $state<Record<string, NavigationChain>>({})
function buildNavigationLinks(): NavigationChain {
const items: string[] = []
// Flow header (always first)
items.push(`flow-${flowId}`)
if (level > 0 && !isExpanded(`flow-${flowId}`)) {
return { [`flow-${flowId}`]: { upId: null, downId: null } }
}
// Flow input (if exists and shown)
if (showResultsInputs && flowInfo.inputs && Object.keys(flowInfo.inputs).length > 0) {
items.push(`flow-${flowId}-input`)
}
// Add modules in order
modules.forEach((module) => {
items.push(module.id)
// If module is expanded, add its children
if (
isExpanded(
module.id,
localModuleStates[module.id]?.type === 'InProgress' ||
localModuleStates[module.id]?.type === 'WaitingForExecutor'
)
) {
// Check if it has subflows
const subflows = getSubflows(module)
if (subflows.length > 0) {
// Add subflow IDs
subflows.forEach((subflow) => {
items.push(`flow-${subflow.flowId}`)
})
} else {
// Add input if exists and shown (leaf nodes only)
if (
showResultsInputs &&
localModuleStates[module.id]?.args &&
Object.keys(localModuleStates[module.id].args).length > 0
) {
items.push(`${module.id}-input`)
}
items.push(`${module.id}-logs`)
// Add result if exists and shown (leaf nodes only)
if (showResultsInputs && localModuleStates[module.id]?.result !== undefined) {
items.push(`${module.id}-result`)
}
}
}
})
// Flow result (if exists and shown)
if (showResultsInputs && flowInfo.result !== undefined && rootJob.type === 'CompletedJob') {
items.push(`flow-${flowId}-result`)
}
// Convert items to navigation links
let links = items.reduce((acc, itemId, index) => {
let upId: string | null = index > 0 ? items[index - 1] : null
let downId: string | null = index < items.length - 1 ? items[index + 1] : null
acc[itemId] = { upId, downId }
return acc
}, {} as NavigationChain)
// Add subflow chains
Object.entries(subloopNavigationChains).forEach(([_, chain]) => {
links = updateLinks($state.snapshot(links), $state.snapshot(chain))
})
return links
}
const links = $derived.by(buildNavigationLinks)
$effect(() => {
navigationChain = links
})
// Helper to check if an item is currently focused
function isCurrent(itemId: string): boolean {
return currentId === itemId
}
// Scroll current item into view
$effect(() => {
if (currentId) {
const element = document.querySelector(`[data-nav-id="${currentId}"]`)
if (element) {
;(element as HTMLElement).focus?.({ preventScroll: true })
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
})
function hasSubflows(module: FlowModule) {
return (
module.value.type === 'forloopflow' ||
@@ -255,6 +364,12 @@
{#if render}
{#if level === 0 && toggleExpandAll}
<div class="flex justify-end gap-4 items-center p-2 bg-surface-secondary border-b">
<Tooltip>
{#snippet text()}
Keyboard navigation available - ↑/↓/Enter
{/snippet}
<Keyboard size={16} class="text-tertiary" />
</Tooltip>
<div class="flex items-center gap-2 whitespace-nowrap">
<label
for="showResultsInputs"
@@ -286,413 +401,366 @@
{/if}
<ul class="w-full font-mono text-xs bg-surface-secondary list-none">
<!-- Flow entry -->
<li class="border-b flex flex-row">
<div class="py-2 leading-tight align-top">
{#if level > 0}
<button
class="w-4 flex items-center justify-center text-xs text-tertiary hover:text-primary transition-colors"
onclick={() => toggleExpanded(`flow-${flowId}`)}
>
{#if isExpanded(`flow-${flowId}`, rootJob.type === 'QueuedJob')}
<ChevronDown size={8} />
{:else}
<ChevronRight size={8} />
{/if}
</button>
{:else}
<!-- Root flow - no collapse button, just spacing -->
<div class="w-4"></div>
{/if}
</div>
<div class="grow min-w-0 leading-tight">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'py-1 flex items-center justify-between pr-2',
level > 0 ? 'cursor-pointer' : '',
rootJob.type === undefined ? 'opacity-50' : ''
)}
onclick={level > 0 ? () => toggleExpanded(`flow-${flowId}`) : undefined}
>
<div class="flex items-center gap-2 grow min-w-0">
<!-- Flow icon -->
{@render flowIcon(level == 0 ? getFlowStatus(rootJob) : flowStatus, flowInfo.hasErrors)}
<FlowLogRow
id={`flow-${flowId}`}
isCollapsible={level > 0}
isRunning={rootJob.type === 'QueuedJob'}
{isCurrent}
{isExpanded}
{toggleExpanded}
class={rootJob.type === undefined ? 'opacity-50' : ''}
{select}
>
{#snippet label()}
<div class="flex items-center gap-2">
<!-- Flow icon -->
{@render flowIcon(level == 0 ? getFlowStatus(rootJob) : flowStatus, flowInfo.hasErrors)}
<div class="flex items-center gap-2">
<span class="text-xs font-mono">
{mode === 'aiagent' ? 'AI Agent' : level == 0 ? 'Flow' : 'Subflow'}
{#if flowInfo.label}
: {flowInfo.label}
{/if}
<span class="text-tertiary">{getStepProgress(rootJob, modules.length)}</span>
</span>
</div>
<div class="text-xs text-left font-mono grow min-w-0">
{mode === 'aiagent' ? 'AI Agent' : level == 0 ? 'Flow' : 'Subflow'}
{#if flowInfo.label}
: {flowInfo.label}
{/if}
<span class="text-tertiary">{getStepProgress(rootJob, modules.length)}</span>
</div>
{#if flowInfo.jobId}
<a
href={getJobLink(flowInfo.jobId)}
class="text-xs text-primary hover:underline font-mono"
class="text-xs text-gray-400 hover:text-primary pl-1"
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
>
{truncateRev(flowInfo.jobId, 6)}
<ExternalLink size={12} />
</a>
{/if}
</div>
{/snippet}
{#if level === 0 || isExpanded(`flow-${flowId}`, rootJob.type === 'QueuedJob')}
<div class="mb-2 transition-all duration-200 ease-in-out w-full">
<!-- Flow logs -->
{#if flowInfo.logs}
<LogViewer
content={flowInfo.logs}
jobId={flowInfo.jobId}
isLoading={false}
small={true}
download={false}
noAutoScroll={true}
tag={undefined}
noPadding
wrapperClass="w-full mb-2 pr-2"
/>
{#if level === 0 || isExpanded(`flow-${flowId}`, rootJob.type === 'QueuedJob')}
<div class="mb-2 transition-all duration-200 ease-in-out w-full">
<!-- Flow logs -->
{#if flowInfo.logs}
<LogViewer
content={flowInfo.logs}
jobId={flowInfo.jobId}
isLoading={false}
small={true}
download={false}
noAutoScroll={true}
tag={undefined}
noPadding
wrapperClass="w-full mb-2 pr-2"
/>
{/if}
<!-- Flow steps - nested as children -->
<ul class="w-full font-mono text-xs bg-surface-secondary list-none border-l">
<!-- Flow inputs as first row entry -->
{#if showResultsInputs && flowInfo.inputs && Object.keys(flowInfo.inputs).length > 0}
<FlowLogRow
id={`flow-${flowId}-input`}
isCollapsible={true}
isRunning={false}
{isCurrent}
{isExpanded}
{toggleExpanded}
{select}
>
{#snippet label()}
<div class="flex items-center gap-2 grow min-w-0">
<ArrowDownToLine size={10} />
<span class="text-xs font-mono">Inputs</span>
</div>
{/snippet}
{#if isExpanded(`flow-${flowId}-input`)}
<div class="my-1 transition-all duration-200 ease-in-out">
<div class="pl-4">
<ObjectViewer json={flowInfo.inputs} pureViewer={true} />
</div>
</div>
{/if}
</FlowLogRow>
{/if}
<!-- Flow steps - nested as children -->
<ul class="w-full font-mono text-xs bg-surface-secondary list-none border-l">
<!-- Flow inputs as first row entry -->
{#if showResultsInputs && flowInfo.inputs && Object.keys(flowInfo.inputs).length > 0}
<li class="border-b flex flex-row w-full">
<div class="py-2 leading-tight align-top">
<button
class="w-4 flex items-center justify-center text-xs text-tertiary hover:text-primary transition-colors"
onclick={() => toggleExpanded(`flow-${flowId}-input`)}
>
{#if isExpanded(`flow-${flowId}-input`)}
<ChevronDown size={8} />
{:else}
<ChevronRight size={8} />
{/if}
</button>
</div>
<div class="grow min-w-0 leading-tight">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
{#if modules.length > 0}
{#each modules as module (module.id)}
{@const isLeafStep = !hasSubflows(module)}
{@const status = localModuleStates[module.id]?.type}
{@const isRunning = status === 'InProgress' || status === 'WaitingForExecutor'}
{@const hasEmptySubflowValue = hasEmptySubflow(module.id, module.value.type)}
{@const isCollapsible = !hasEmptySubflowValue}
<FlowLogRow
id={module.id}
{isCollapsible}
{isRunning}
{isCurrent}
{isExpanded}
{toggleExpanded}
{select}
>
{#snippet label()}
<div
class="py-1 flex items-center justify-between pr-2 cursor-pointer"
onclick={() => toggleExpanded(`flow-${flowId}-input`)}
class={twMerge(
'flex items-center justify-between',
status === 'WaitingForPriorSteps' ||
status === 'WaitingForEvents' ||
status === 'WaitingForExecutor' ||
status === undefined
? 'opacity-50'
: ''
)}
>
<div class="flex items-center gap-2 grow min-w-0">
<ArrowDownToLine size={10} />
<span class="text-xs font-mono">Inputs</span>
</div>
</div>
{#if isExpanded(`flow-${flowId}-input`)}
<div class="my-1 transition-all duration-200 ease-in-out">
<div class="pl-4">
<ObjectViewer json={flowInfo.inputs} pureViewer={true} />
</div>
</div>
{/if}
</div>
</li>
{/if}
{#if modules.length > 0}
{#each modules as module (module.id)}
{@const isLeafStep = !hasSubflows(module)}
{@const status = localModuleStates[module.id]?.type}
{@const isRunning = status === 'InProgress' || status === 'WaitingForExecutor'}
{@const hasEmptySubflowValue = hasEmptySubflow(module.id, module.value.type)}
{@const isCollapsible = !hasEmptySubflowValue}
<li class="border-b flex flex-row">
<div class="py-2 leading-tight align-top">
{#if isCollapsible}
<button
class="w-4 flex items-center justify-center text-xs text-tertiary hover:text-primary transition-colors"
onclick={() => toggleExpanded(module.id)}
>
{#if isExpanded(module.id, isRunning)}
<ChevronDown size={8} />
{:else}
<ChevronRight size={8} />
{/if}
</button>
{:else}
<!-- Empty subflow - no collapse button, just spacing -->
<div class="w-4"></div>
{/if}
</div>
<div class="w-full leading-tight grow min-w-0">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'py-1 flex items-center justify-between pr-2',
isCollapsible ? 'cursor-pointer' : '',
status === 'WaitingForPriorSteps' ||
status === 'WaitingForEvents' ||
status === 'WaitingForExecutor' ||
status === undefined
? 'opacity-50'
: ''
<!-- Step icon -->
{@render stepIcon(
module.value.type,
status as FlowStatusModule['type'],
flowInfo.parentsWithErrors.has(module.id)
)}
onclick={isCollapsible ? () => toggleExpanded(module.id) : undefined}
>
<div class="flex items-center gap-2 grow min-w-0">
<!-- Step icon -->
{@render stepIcon(
module.value.type,
status as FlowStatusModule['type'],
flowInfo.parentsWithErrors.has(module.id)
)}
<div class="flex items-center gap-2">
<span class="text-xs font-mono">
<b>
{mode === 'aiagent'
? module.summary
? 'Tool call'
: 'Message'
: module.id}
</b>
{#if mode === 'flow'}
{#if module.value.type === 'forloopflow'}
For loop
{:else if module.value.type === 'whileloopflow'}
While loop
{:else if module.value.type === 'branchall'}
Branch to all
{:else if module.value.type === 'branchone'}
Branch to one
{:else if module.value.type === 'flow'}
Subflow
{:else}
Step
{/if}
<div class="flex items-center gap-2">
<span class="text-xs font-mono text-left">
<b>
{mode === 'aiagent'
? module.summary
? 'Tool call'
: 'Message'
: module.id}
</b>
{#if mode === 'flow'}
{#if module.value.type === 'forloopflow'}
For loop
{:else if module.value.type === 'whileloopflow'}
While loop
{:else if module.value.type === 'branchall'}
Branch to all
{:else if module.value.type === 'branchone'}
Branch to one
{:else if module.value.type === 'flow'}
Subflow
{:else}
Step
{/if}
{#if module.summary}
: {module.summary}
{/if}
{#if hasEmptySubflowValue}
<span class="text-tertiary">
{#if module.value.type === 'forloopflow' || module.value.type === 'whileloopflow'}
(empty loop)
{:else if module.value.type === 'branchall' || module.value.type === 'branchone'}
(no branch)
{/if}
</span>
{/if}
</span>
{#if !hasEmptySubflowValue && localModuleStates[module.id]?.flow_jobs && (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow')}
<span
class="text-xs font-mono font-medium inline-flex items-center grow min-w-0 -my-2"
>
<span onclick={(e) => e.stopPropagation()}>
<FlowJobsMenu
moduleId={module.id}
id={module.id}
{onSelectedIteration}
flowJobsSuccess={localModuleStates[module.id]
?.flow_jobs_success}
flowJobs={localModuleStates[module.id]?.flow_jobs}
selected={localModuleStates[module.id]?.selectedForloopIndex ??
0}
selectedManually={localModuleStates[module.id]
?.selectedForLoopSetManually ?? false}
showIcon={false}
/>
</span>
{#if module.value.type === 'forloopflow'}
{`/${localModuleStates[module.id]?.iteration_total ?? 0}`}
{/if}
{#if module.summary}
: {module.summary}
{/if}
{#if hasEmptySubflowValue}
<span class="text-tertiary">
{#if module.value.type === 'forloopflow' || module.value.type === 'whileloopflow'}
(empty loop)
{:else if module.value.type === 'branchall' || module.value.type === 'branchone'}
(no branch)
{/if}
</span>
{/if}
</div>
</div>
{#if isLeafStep}
{@const jobId = localModuleStates[module.id]?.job_id}
{#if jobId}
<a
href={getJobLink(jobId ?? '')}
class="text-xs text-primary hover:underline font-mono"
target="_blank"
rel="noopener noreferrer"
</span>
{#if !hasEmptySubflowValue && localModuleStates[module.id]?.flow_jobs && (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow')}
<span
class="text-xs font-mono font-medium inline-flex items-center grow min-w-0 -my-2"
>
{truncateRev(jobId ?? '', 6)}
</a>
<button onclick={(e) => e.stopPropagation()}>
<FlowJobsMenu
moduleId={module.id}
id={module.id}
{onSelectedIteration}
flowJobsSuccess={localModuleStates[module.id]?.flow_jobs_success}
flowJobs={localModuleStates[module.id]?.flow_jobs}
selected={localModuleStates[module.id]?.selectedForloopIndex ?? 0}
selectedManually={localModuleStates[module.id]
?.selectedForLoopSetManually ?? false}
showIcon={false}
/>
</button>
{#if module.value.type === 'forloopflow'}
{`/${localModuleStates[module.id]?.iteration_total ?? 0}`}
{/if}
</span>
{/if}
{/if}
</div>
</div>
{#if isCollapsible && isExpanded(module.id, isRunning)}
{@const args = localModuleStates[module.id]?.args}
{@const logs = localModuleStates[module.id]?.logs}
{@const result = localModuleStates[module.id]?.result}
{#if isLeafStep}
{@const jobId = localModuleStates[module.id]?.job_id}
<div class="my-1 transition-all duration-200 ease-in-out">
<!-- Show child steps if they exist -->
{#each getSubflows(module) as subflow}
{@const subflowJob = {
id: jobId,
type:
localModuleStates[module.id]?.type === 'Failure' ||
localModuleStates[module.id]?.type === 'Success'
? 'CompletedJob'
: ('QueuedJob' as Job['type']),
logs,
result,
args,
success: localModuleStates[module.id]?.type === 'Success'
}}
<div class="border-l mb-2">
<!-- Recursively render child steps using FlowLogViewer -->
<FlowLogViewer
modules={subflow.modules}
{localModuleStates}
rootJob={subflowJob}
flowStatus={localModuleStates[module.id]?.type}
{expandedRows}
{allExpanded}
{showResultsInputs}
{toggleExpanded}
toggleExpandAll={undefined}
{workspaceId}
{render}
level={level + 1}
flowId={subflow.flowId}
flowSummary={subflow.label}
{onSelectedIteration}
{getSelectedIteration}
/>
</div>
{/each}
<!-- Show input arguments -->
{#if getSubflows(module).length === 0}
{#if showResultsInputs && isLeafStep && args && Object.keys(args).length > 0}
<div class="mb-2">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center gap-1 cursor-pointer hover:text-primary text-xs font-mono font-medium mb-1"
onclick={() => toggleExpanded(`${module.id}-input`)}
>
{#if isExpanded(`${module.id}-input`)}
<ChevronDown size={8} />
{:else}
<ChevronRight size={8} />
{/if}
Input
</div>
{#if isExpanded(`${module.id}-input`)}
<div class="pl-4">
<ObjectViewer json={args} pureViewer={true} />
</div>
{/if}
</div>
{/if}
<!-- Show logs if they exist -->
{#if logs}
<LogViewer
content={logs}
jobId={jobId ?? ''}
isLoading={false}
small={true}
download={false}
noAutoScroll={true}
tag={undefined}
noPadding
wrapperClass="w-full mb-2 pr-2"
/>
{:else if jobId && !hasSubflows(module)}
<div class="mb-2">
<div class="text-xs text-tertiary font-mono">
No logs available
</div>
</div>
{/if}
<!-- Show result if completed -->
{#if showResultsInputs && isLeafStep && result !== undefined && (status === 'Success' || status === 'Failure')}
<div class="mb-2 mt-2">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center gap-1 cursor-pointer hover:text-primary text-xs font-mono font-medium mb-1"
onclick={() => toggleExpanded(`${module.id}-result`)}
>
{#if isExpanded(`${module.id}-result`)}
<ChevronDown size={8} />
{:else}
<ChevronRight size={8} />
{/if}
Result
</div>
{#if isExpanded(`${module.id}-result`)}
<div class="pl-4">
<ObjectViewer json={result} pureViewer={true} />
</div>
{/if}
</div>
{/if}
{/if}
</div>
{#if jobId}
<a
href={getJobLink(jobId ?? '')}
class="text-xs text-gray-400 hover:text-primary pl-1"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink size={12} />
</a>
{/if}
{/if}
</div>
</li>
{/each}
{/if}
{/snippet}
<!-- Flow result as last row entry -->
{#if showResultsInputs && flowInfo.result !== undefined && rootJob.type === 'CompletedJob'}
<li class="border-b flex">
<div class="py-2 leading-tight align-top">
<button
class="w-4 flex items-center justify-center text-xs text-tertiary hover:text-primary transition-colors"
onclick={() => toggleExpanded(`flow-${flowId}-result`)}
>
{#if isExpanded(`flow-${flowId}-result`)}
<ChevronDown size={8} />
{:else}
<ChevronRight size={8} />
{/if}
</button>
</div>
<div class="w-full leading-tight">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="py-1 flex items-center justify-between pr-2 cursor-pointer"
onclick={() => toggleExpanded(`flow-${flowId}-result`)}
>
<div class="flex items-center gap-2 grow min-w-0">
<ArrowDownFromLine size={10} />
<span class="text-xs font-mono">Results</span>
</div>
</div>
{#if isExpanded(`flow-${flowId}-result`)}
<div class="my-1 transition-all duration-200 ease-in-out">
<div class="pl-4">
<ObjectViewer json={flowInfo.result} pureViewer={true} />
{#if isCollapsible && isExpanded(module.id, isRunning)}
{@const args = localModuleStates[module.id]?.args}
{@const logs = localModuleStates[module.id]?.logs}
{@const result = localModuleStates[module.id]?.result}
{@const jobId = localModuleStates[module.id]?.job_id}
<div class="my-1 transition-all duration-200 ease-in-out border-l">
<!-- Show child steps if they exist -->
{#each getSubflows(module) as subflow}
{@const subflowJob = {
id: jobId,
type:
localModuleStates[module.id]?.type === 'Failure' ||
localModuleStates[module.id]?.type === 'Success'
? 'CompletedJob'
: ('QueuedJob' as Job['type']),
logs,
result,
args,
success: localModuleStates[module.id]?.type === 'Success'
}}
<div class="border-l mb-2">
<!-- Recursively render child steps using FlowLogViewer -->
<FlowLogViewer
modules={subflow.modules}
{localModuleStates}
rootJob={subflowJob}
flowStatus={localModuleStates[module.id]?.type}
{expandedRows}
{allExpanded}
{showResultsInputs}
{toggleExpanded}
toggleExpandAll={undefined}
{workspaceId}
{render}
level={level + 1}
flowId={subflow.flowId}
flowSummary={subflow.label}
{onSelectedIteration}
{getSelectedIteration}
{currentId}
bind:navigationChain={subloopNavigationChains[subflow.flowId]}
{select}
/>
</div>
</div>
{/if}
{/each}
{#if getSubflows(module).length === 0}
<!-- Show input arguments -->
{#if showResultsInputs && isLeafStep && args && Object.keys(args).length > 0}
<FlowLogRow
id={`${module.id}-input`}
isCollapsible={true}
isRunning={false}
{isCurrent}
{isExpanded}
{toggleExpanded}
{select}
>
{#snippet label()}
<div class="flex items-center gap-2 grow min-w-0">
<ArrowDownFromLine size={10} />
<span class="text-xs font-mono">Input</span>
</div>
{/snippet}
{#if isExpanded(`${module.id}-input`)}
<div class="pl-4">
<ObjectViewer json={args} pureViewer={true} />
</div>
{/if}
</FlowLogRow>
{/if}
<!-- Show logs if they exist -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#if logs}
<div onclick={() => select(`${module.id}-logs`)}>
<LogViewer
content={logs}
jobId={jobId ?? ''}
isLoading={false}
small={true}
download={false}
noAutoScroll={true}
tag={undefined}
noPadding
wrapperClass={twMerge(
'w-full mb-2 px-2',
isCurrent(`${module.id}-logs`)
? 'border-l-2 border-l-gray-400 -ml-[2px]'
: ''
)}
navigationId={`${module.id}-logs`}
/>
</div>
{:else if jobId && !hasSubflows(module)}
<div
class={twMerge(
'w-full mb-2 px-2',
isCurrent(`${module.id}-logs`)
? 'border-l-2 border-l-gray-400 -ml-[2px]'
: ''
)}
>
<div class="text-xs text-tertiary font-mono"> No logs available </div>
</div>
{/if}
<!-- Show result if completed -->
{#if showResultsInputs && isLeafStep && result !== undefined && (status === 'Success' || status === 'Failure')}
<FlowLogRow
id={`${module.id}-result`}
isCollapsible={true}
isRunning={false}
{isCurrent}
{isExpanded}
{toggleExpanded}
{select}
>
{#snippet label()}
<div class="flex items-center gap-2 grow min-w-0">
<ArrowDownFromLine size={10} />
<span class="text-xs font-mono">Result</span>
</div>
{/snippet}
{#if isExpanded(`${module.id}-result`)}
<div class="pl-4">
<ObjectViewer json={result} pureViewer={true} />
</div>
{/if}
</FlowLogRow>
{/if}
{/if}
</div>
{/if}
</FlowLogRow>
{/each}
{/if}
<!-- Flow result as last row entry -->
{#if showResultsInputs && flowInfo.result !== undefined && rootJob.type === 'CompletedJob'}
<FlowLogRow
id={`flow-${flowId}-result`}
isCollapsible={true}
isRunning={false}
{isCurrent}
{isExpanded}
{toggleExpanded}
{select}
>
{#snippet label()}
<div class="flex items-center gap-2 grow min-w-0">
<ArrowDownFromLine size={10} />
<span class="text-xs font-mono">Results</span>
</div>
</li>
{/if}
</ul>
</div>
{/if}
</div>
</li>
{/snippet}
<ObjectViewer json={flowInfo.result} pureViewer={true} />
</FlowLogRow>
{/if}
</ul>
</div>
{/if}
</FlowLogRow>
</ul>
{/if}
@@ -735,9 +803,3 @@
{/if}
</div>
{/snippet}
<style>
.transition-all {
transition: all 0.2s ease-in-out;
}
</style>
@@ -5,6 +5,7 @@
import { untrack } from 'svelte'
import { ChangeTracker } from '$lib/svelte5Utils.svelte'
import { readFieldsRecursively } from '$lib/utils'
import type { NavigationChain } from '$lib/keyboardChain'
interface Props {
job: Partial<Job>
@@ -33,6 +34,10 @@
let allExpanded = $state(false)
let showResultsInputs = $state(true)
// Keyboard navigation state - incremental like expandedRows
let currentId = $state<string | null>('flow-root')
let navigationChain = $state<NavigationChain>({})
let moduleTracker = new ChangeTracker($state.snapshot(job.raw_flow?.modules ?? []))
$effect(() => {
readFieldsRecursively(job.raw_flow?.modules ?? [])
@@ -58,9 +63,48 @@
allExpanded = !allExpanded
expandedRows = {}
}
// Keyboard event handler using navigation links
function handleKeydown(event: KeyboardEvent) {
if (!currentId && job.raw_flow?.modules) {
currentId = 'flow-root'
} else if (!currentId) {
return
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
const downId = navigationChain[currentId]?.downId
if (downId) {
currentId = downId
}
break
case 'ArrowUp':
event.preventDefault()
const upId = navigationChain[currentId]?.upId
if (upId) {
currentId = upId
}
break
case 'Enter':
event.preventDefault()
toggleExpanded(currentId)
break
}
}
function select(id: string) {
currentId = id
}
</script>
<div class="w-full rounded-md overflow-hidden border">
<div
class="w-full rounded-md overflow-hidden border focus:border-gray-400 dark:focus:border-gray-400"
role="tree"
tabindex="0"
onkeydown={handleKeydown}
>
<FlowLogViewer
{modules}
{localModuleStates}
@@ -77,5 +121,8 @@
flowId="root"
flowStatus={undefined}
{mode}
{currentId}
bind:navigationChain
{select}
/>
</div>
+86 -72
View File
@@ -34,6 +34,7 @@
customEmptyMessage?: string
tagLabel?: string
noPadding?: boolean
navigationId?: string
}
let {
@@ -51,7 +52,8 @@
download = true,
customEmptyMessage = 'No logs are available yet',
tagLabel = undefined,
noPadding = false
noPadding = false,
navigationId = undefined
}: Props = $props()
// @ts-ignore
@@ -217,78 +219,85 @@
</DrawerContent>
</Drawer>
<div class="relative w-full h-full {wrapperClass}">
<div
bind:this={div}
class="w-full h-full overflow-auto relative bg-surface-secondary {noMaxH ? '' : 'max-h-screen'}"
>
<div class="sticky z-10 top-0 right-0 w-full flex flex-row-reverse justify-between text-sm">
<div class="flex gap-2 pl-0.5 bg-surface-secondary">
{#if jobId && download}
<div class="flex items-center">
<a
class="text-primary pb-0.5"
target="_blank"
href="{base}/api/w/{$workspaceStore}/jobs_u/get_logs/{jobId}"
download="windmill_logs_{jobId}.txt"
><Download size="14" />
</a>
</div>
{/if}
<button onclick={logViewer.openDrawer}><Expand size="12" /></button>
{#if !noAutoScroll}
<div
class="{small ? '' : 'py-2'} pr-2 {small
? '!text-2xs'
: '!text-xs'} flex gap-2 text-tertiary items-center"
>
Auto scroll
<input class="windmillapp" type="checkbox" bind:checked={scroll} />
</div>
{/if}
</div>
</div>
{#if isLoading}
<div class="flex gap-2 absolute top-2 left-2 items-center z-10">
<Loader2 class="animate-spin" />
{#if tag}
<div class="flex flex-row items-center gap-1">
<div class="text-secondary {small ? '!text-2xs' : '!text-xs'}"
>{tagLabel ?? 'tag'}: {tag}</div
>
<NoWorkerWithTagWarning {tagLabel} {tag} />
</div>
{/if}
</div>
{:else if duration}
<span
class="absolute {small ? '!text-2xs' : '!text-xs'} text-tertiary dark:text-gray-400 {small
? 'top-0'
: 'top-2'} left-2">took {duration}ms</span
>
{/if}
{#if mem}
<span
class="absolute {small ? '!text-2xs' : '!text-xs'} text-tertiary dark:text-gray-400 {small
? 'top-0'
: 'top-2'} left-36">mem peak: {(mem / 1024).toPrecision(4)}MB</span
>
{/if}
<pre
class={twMerge(
'whitespace-pre break-words w-full',
small ? '!text-2xs' : '!text-xs',
noPadding ? '' : 'p-2'
)}
>{#if content}{@const len =
(content?.length ?? 0) +
(loadedFromObjectStore?.length ?? 0)}{#if downloadStartUrl}<button onclick={getStoreLogs}
>Show more... &nbsp;<Tooltip>{tooltipText(prefixIndex)}</Tooltip></button
><br />{:else if len > LOG_LIMIT}(truncated to the last {LOG_LIMIT} characters)<br
/><button onclick={() => showMoreTruncate(len)}>Show more..</button><br />{/if}<span
>{@html html}</span
>{:else if !isLoading}<span>{customEmptyMessage}</span>{/if}</pre
<div class="w-full h-full {wrapperClass}">
<div class="w-full h-full relative">
<div
bind:this={div}
class="w-full h-full overflow-auto bg-surface-secondary pt-4 {noMaxH ? '' : 'max-h-screen'}"
data-nav-id={navigationId}
>
<div class="absolute z-10 top-0 right-0 flex flex-row-reverse justify-between text-sm">
<div class="flex gap-2">
{#if jobId && download}
<div class="flex items-center">
<a
class="text-primary pb-0.5"
target="_blank"
href="{base}/api/w/{$workspaceStore}/jobs_u/get_logs/{jobId}"
download="windmill_logs_{jobId}.txt"
><Download size="14" />
</a>
</div>
{/if}
<button onclick={logViewer.openDrawer}><Expand size="12" /></button>
{#if !noAutoScroll}
<div
class="{small ? '' : 'py-2'} pr-2 {small
? '!text-2xs'
: '!text-xs'} flex gap-2 text-tertiary items-center"
>
Auto scroll
<input class="windmillapp" type="checkbox" bind:checked={scroll} />
</div>
{/if}
</div>
</div>
{#if isLoading}
<div class="flex gap-2 absolute top-2 left-2 items-center z-10">
<Loader2 class="animate-spin" />
{#if tag}
<div class="flex flex-row items-center gap-1">
<div class="text-secondary {small ? '!text-2xs' : '!text-xs'}"
>{tagLabel ?? 'tag'}: {tag}</div
>
<NoWorkerWithTagWarning {tagLabel} {tag} />
</div>
{/if}
</div>
{:else if duration}
<span
class={twMerge(
'absolute text-tertiary dark:text-gray-400',
small ? '!text-2xs' : '!text-xs',
small ? 'top-0' : 'top-2',
noPadding ? '' : 'left-2'
)}>took {duration}ms</span
>
{/if}
{#if mem}
<span
class="absolute {small ? '!text-2xs' : '!text-xs'} text-tertiary dark:text-gray-400 {small
? 'top-0'
: 'top-2'} left-36">mem peak: {(mem / 1024).toPrecision(4)}MB</span
>
{/if}
<pre
class={twMerge(
'whitespace-pre break-words w-full',
small ? '!text-2xs' : '!text-xs',
noPadding ? '' : 'p-2'
)}
>{#if content}{@const len =
(content?.length ?? 0) +
(loadedFromObjectStore?.length ?? 0)}{#if downloadStartUrl}<button
onclick={getStoreLogs}
>Show more... &nbsp;<Tooltip>{tooltipText(prefixIndex)}</Tooltip></button
><br />{:else if len > LOG_LIMIT}(truncated to the last {LOG_LIMIT} characters)<br
/><button onclick={() => showMoreTruncate(len)}>Show more..</button><br />{/if}<span
>{@html html}</span
>{:else if !isLoading}<span>{customEmptyMessage}</span>{/if}</pre
>
</div>
</div>
</div>
@@ -496,4 +505,9 @@
.dark .ansi-bright-white-bg {
background-color: rgb(229, 233, 240);
}
[data-nav-id]:focus-visible {
outline: none;
outline-offset: 0;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
export type NavigationChain = Record<string, { upId?: string | null; downId?: string | null }>
// Update navigation chain: remove old chain between first/last, insert new chain
export function updateLinks(navigationChain: NavigationChain, newChain: NavigationChain) {
if (Object.keys(newChain).length === 0) return navigationChain
const firstLink = Object.keys(newChain).find((link) => newChain[link].upId === null)
const lastLink = Object.keys(newChain).find((link) => newChain[link].downId === null)
//If the chain has no end or start, return the navigationChain
if (!firstLink || !lastLink) return navigationChain
const cleaned = { ...navigationChain }
const inserted = { ...newChain }
// Check if the chain needs to be inserted
if (!cleaned[lastLink] && cleaned[firstLink]) {
// connect the beginning of the new chain to the beginning of the chain
inserted[firstLink].upId = cleaned[firstLink]?.upId
//connect the end of new the new chain to the beginning of the chain
const nextLink = cleaned[firstLink]?.downId
if (nextLink) {
inserted[lastLink].downId = nextLink
cleaned[nextLink].upId = lastLink
}
} else if (cleaned[lastLink] && cleaned[firstLink]) {
// If the end of the chain matches a link in the navigationChain, and the beginning delete the chain in the middle and replace it with the new chain
inserted[firstLink].upId = cleaned[firstLink]?.upId
inserted[lastLink].downId = cleaned[lastLink]?.downId
if (firstLink !== lastLink) {
const toDelete: string[] = []
let current = cleaned[firstLink]?.downId
while (current && current !== lastLink && !toDelete.includes(current)) {
toDelete.push(current)
current = cleaned[current]?.downId
}
toDelete.forEach((itemId) => {
delete cleaned[itemId]
})
}
}
return { ...cleaned, ...inserted }
}