mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 00:06:06 +00:00
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:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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... <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... <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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user