mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 08:02:26 +00:00
1021 lines
32 KiB
Svelte
1021 lines
32 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
GitBranch,
|
|
Repeat,
|
|
Code,
|
|
ArrowDownToLine,
|
|
ArrowDownFromLine,
|
|
FoldVertical,
|
|
UnfoldVertical,
|
|
ExternalLink,
|
|
Keyboard
|
|
} from 'lucide-svelte'
|
|
import { base } from '$lib/base'
|
|
import { workspaceStore } from '$lib/stores'
|
|
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
|
|
import LogViewer from './LogViewer.svelte'
|
|
import FlowLogViewer from './FlowLogViewer.svelte'
|
|
import type { FlowModule, FlowModuleValue, FlowStatusModule, Job } from '$lib/gen'
|
|
import { twMerge } from 'tailwind-merge'
|
|
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'
|
|
import FlowTimelineBar from './FlowTimelineBar.svelte'
|
|
|
|
type RootJobData = Partial<Job>
|
|
|
|
interface Props {
|
|
modules: FlowModule[]
|
|
localModuleStates: Record<string, GraphModuleState>
|
|
rootJob: RootJobData | undefined
|
|
expandedRows: Record<string, boolean>
|
|
allExpanded?: boolean
|
|
showResultsInputs?: boolean
|
|
toggleExpanded: (id: string) => void
|
|
toggleExpandAll?: () => void
|
|
workspaceId: string | undefined
|
|
render: boolean
|
|
level?: number
|
|
flowId: string
|
|
onSelectedIteration?: (
|
|
detail:
|
|
| { id: string; index: number; manuallySet: true; moduleId: string }
|
|
| { manuallySet: false; moduleId: string }
|
|
) => Promise<void>
|
|
getSelectedIteration: (stepId: string) => number
|
|
flowSummary?: string
|
|
mode?: 'flow' | 'aiagent'
|
|
currentId?: string | null
|
|
navigationChain?: NavigationChain
|
|
select: (id: string) => void
|
|
timelineMin?: number
|
|
timelineTotal?: number
|
|
timelineItems?: Record<
|
|
string,
|
|
Array<{ created_at?: number; started_at?: number; duration_ms?: number; id: string }>
|
|
>
|
|
timelineNow: number
|
|
timelineAvailableWidths: Record<string, number>
|
|
timelinelWidth: number
|
|
showTimeline?: boolean
|
|
}
|
|
|
|
let {
|
|
modules,
|
|
localModuleStates,
|
|
rootJob,
|
|
expandedRows,
|
|
allExpanded,
|
|
showResultsInputs,
|
|
toggleExpanded,
|
|
toggleExpandAll,
|
|
workspaceId,
|
|
render,
|
|
level = 0,
|
|
flowId = 'root',
|
|
onSelectedIteration,
|
|
getSelectedIteration,
|
|
flowSummary,
|
|
mode = 'flow',
|
|
currentId,
|
|
navigationChain = $bindable(),
|
|
select,
|
|
timelineMin: timelineMinAbsolute,
|
|
timelineTotal: timelineTotalAbsolute,
|
|
timelineItems,
|
|
timelineNow,
|
|
timelineAvailableWidths = $bindable(),
|
|
timelinelWidth,
|
|
showTimeline = true
|
|
}: Props = $props()
|
|
|
|
function getJobLink(jobId: string | undefined): string {
|
|
if (!jobId) return ''
|
|
return `${base}/run/${jobId}?workspace=${workspaceId ?? $workspaceStore}`
|
|
}
|
|
|
|
function getStatusColor(status: FlowStatusModule['type'] | undefined): string {
|
|
const statusColors = {
|
|
Success: 'text-green-500',
|
|
Failure: 'text-red-500',
|
|
InProgress: 'text-yellow-500',
|
|
WaitingForPriorSteps: 'text-gray-400',
|
|
WaitingForEvents: 'text-purple-400',
|
|
WaitingForExecutor: 'text-gray-400'
|
|
}
|
|
return status ? statusColors[status] : 'text-gray-400'
|
|
}
|
|
|
|
function getFlowStatus(job: RootJobData | undefined): FlowStatusModule['type'] | undefined {
|
|
if (!job) return undefined
|
|
if (job.type === 'CompletedJob') {
|
|
return job.success ? 'Success' : 'Failure'
|
|
} else if (job.type === 'QueuedJob') {
|
|
return 'InProgress'
|
|
} else {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
function getStepProgress(job: RootJobData | undefined, totalSteps: number): string {
|
|
if (!job || totalSteps === 0) return ''
|
|
|
|
const stepWord = mode === 'aiagent' ? 'action' : 'step'
|
|
|
|
// If flow is completed, show total steps
|
|
if (job.type === 'CompletedJob') {
|
|
return ` (${totalSteps} ${stepWord}${totalSteps === 1 ? '' : 's'})`
|
|
}
|
|
|
|
// If flow is running, use flow_status.step if available (like JobStatus.svelte)
|
|
if (job.type === 'QueuedJob') {
|
|
if (job.flow_status?.step !== undefined) {
|
|
const currentStep = (job.flow_status.step ?? 0) + 1
|
|
return ` (${stepWord} ${currentStep} of ${totalSteps})`
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
function isExpanded(id: string, isRunning: boolean = false): boolean {
|
|
// If explicitly set in expandedRows, use that value
|
|
// Otherwise, fall back to allExpanded
|
|
return expandedRows[id] ?? (allExpanded || isRunning)
|
|
}
|
|
|
|
function hasEmptySubflow(stepId: string, stepType: FlowModuleValue['type'] | undefined): boolean {
|
|
const state = localModuleStates[stepId]
|
|
|
|
if (!state || !stepType) return false
|
|
return (
|
|
['forloopflow', 'whileloopflow'].includes(stepType) &&
|
|
(!state.flow_jobs || state.flow_jobs.length === 0)
|
|
)
|
|
}
|
|
|
|
// Find all parents of error steps
|
|
function findParentsOfErrors(modules: FlowModule[]): Set<string> {
|
|
const parentsWithErrors = new Set<string>()
|
|
|
|
function traverseModules(modules: FlowModule[], parentId?: string) {
|
|
let hasChildError = false
|
|
|
|
for (const module of modules) {
|
|
let currentEntryHasError = false
|
|
|
|
// Check if this entry has subflows with errors
|
|
if (
|
|
module.value.type === 'forloopflow' ||
|
|
(module.value.type === 'whileloopflow' && module.value.modules.length > 0)
|
|
) {
|
|
const subflowHasError = traverseModules(module.value.modules, module.id)
|
|
if (subflowHasError) {
|
|
currentEntryHasError = true
|
|
parentsWithErrors.add(module.id)
|
|
}
|
|
} else if (module.value.type === 'branchone' || module.value.type === 'branchall') {
|
|
if (module.value.branches.length > 0) {
|
|
for (const branch of module.value.branches) {
|
|
const subflowHasError = traverseModules(branch.modules, module.id)
|
|
if (subflowHasError) {
|
|
currentEntryHasError = true
|
|
parentsWithErrors.add(module.id)
|
|
}
|
|
}
|
|
}
|
|
if (module.value.type === 'branchone' && module.value.default.length > 0) {
|
|
// Also check default branch
|
|
const subflowHasError = traverseModules(module.value.default, module.id)
|
|
if (subflowHasError) {
|
|
currentEntryHasError = true
|
|
parentsWithErrors.add(module.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if this entry itself has an error (but don't flag it - only its parents)
|
|
const stepStatus = localModuleStates[module.id]?.type
|
|
if (stepStatus === 'Failure') {
|
|
currentEntryHasError = true
|
|
// Don't add the entry itself to parentsWithErrors
|
|
}
|
|
|
|
// If this entry has an error, mark its parent
|
|
if (currentEntryHasError && parentId) {
|
|
parentsWithErrors.add(parentId)
|
|
hasChildError = true
|
|
}
|
|
}
|
|
|
|
return hasChildError
|
|
}
|
|
|
|
traverseModules(modules, flowId)
|
|
return parentsWithErrors
|
|
}
|
|
|
|
// Get flow info for display
|
|
const flowInfo = $derived.by(() => {
|
|
if (!rootJob) return undefined
|
|
const parentsWithErrors = findParentsOfErrors(modules)
|
|
return {
|
|
jobId: rootJob.id,
|
|
inputs: rootJob.args || {},
|
|
result: rootJob.type === 'CompletedJob' ? rootJob.result : undefined,
|
|
logs: rootJob.logs || '',
|
|
status: rootJob.type,
|
|
label: flowSummary,
|
|
hasErrors: parentsWithErrors.has(flowId),
|
|
parentsWithErrors
|
|
}
|
|
})
|
|
|
|
let subloopNavigationChains = $state<Record<string, NavigationChain>>({})
|
|
|
|
let useRelativeTimeline = $state(false)
|
|
|
|
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 &&
|
|
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 &&
|
|
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' ||
|
|
module.value.type === 'whileloopflow' ||
|
|
module.value.type === 'branchall' ||
|
|
module.value.type === 'branchone'
|
|
)
|
|
}
|
|
|
|
function getSubflows(module: FlowModule) {
|
|
const subflows: Array<{ modules: FlowModule[]; label: string; flowId: string }> = []
|
|
|
|
if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') {
|
|
subflows.push({
|
|
modules: module.value.modules,
|
|
label: module.summary || '',
|
|
flowId: `${module.id}-subflow`
|
|
})
|
|
} else if (module.value.type === 'branchall' || module.value.type === 'branchone') {
|
|
// Add default branch for branchone
|
|
if (module.value.type === 'branchone') {
|
|
subflows.push({
|
|
modules: module.value.default,
|
|
label: 'default',
|
|
flowId: `${module.id}-subflow-default`
|
|
})
|
|
}
|
|
|
|
// Add all branches
|
|
for (let i = 0; i < module.value.branches.length; i++) {
|
|
const branch = module.value.branches[i]
|
|
subflows.push({
|
|
modules: branch.modules,
|
|
label: branch.summary || `branch ${i + 1}`,
|
|
flowId: `${module.id}-subflow-${i}`
|
|
})
|
|
}
|
|
}
|
|
|
|
return subflows
|
|
}
|
|
|
|
const { timelineMin, timelineTotal } = $derived({
|
|
timelineMin:
|
|
useRelativeTimeline && rootJob?.started_at
|
|
? new Date(rootJob.started_at).getTime()
|
|
: timelineMinAbsolute,
|
|
timelineTotal:
|
|
useRelativeTimeline && rootJob?.['duration_ms']
|
|
? rootJob['duration_ms']
|
|
: timelineTotalAbsolute
|
|
})
|
|
|
|
function getSubflowJob(
|
|
moduleId: string,
|
|
idx: number,
|
|
branchChosen: number | undefined,
|
|
moduleType: FlowModuleValue['type']
|
|
) {
|
|
// if a branch is chosen, ignore the other branches
|
|
if (branchChosen !== undefined && branchChosen !== idx) {
|
|
return undefined
|
|
}
|
|
|
|
const jobType =
|
|
localModuleStates[moduleId]?.type === 'Failure' ||
|
|
localModuleStates[moduleId]?.type === 'Success'
|
|
? 'CompletedJob'
|
|
: ('QueuedJob' as Job['type'])
|
|
let jobId = localModuleStates[moduleId]?.job_id
|
|
let timelineItem = timelineItems?.[moduleId]?.find((item) => item.id === jobId)
|
|
let success = localModuleStates[moduleId]?.type === 'Success'
|
|
let result = localModuleStates[moduleId]?.result
|
|
|
|
// if the subflow is part of a loop or branchAll
|
|
if (localModuleStates[moduleId]?.flow_jobs) {
|
|
const index =
|
|
moduleType === 'forloopflow' || moduleType === 'whileloopflow'
|
|
? (localModuleStates[moduleId]?.selectedForloopIndex ?? idx)
|
|
: idx
|
|
jobId = localModuleStates[moduleId]?.flow_jobs[index]
|
|
timelineItem = timelineItems?.[moduleId]?.find((item) => item.id === jobId)
|
|
result = localModuleStates[moduleId]?.flow_jobs_results?.[index]
|
|
success = localModuleStates[moduleId]?.flow_jobs_success?.[index] ?? false
|
|
}
|
|
return {
|
|
id: jobId,
|
|
type: jobType,
|
|
logs: localModuleStates[moduleId]?.logs,
|
|
result: result,
|
|
args: localModuleStates[moduleId]?.args,
|
|
success: success,
|
|
started_at: timelineItem?.started_at,
|
|
created_at: timelineItem?.created_at,
|
|
duration_ms: timelineItem?.duration_ms
|
|
} as RootJobData
|
|
}
|
|
|
|
function getSelectedIndex(
|
|
moduleId: string,
|
|
moduleItems:
|
|
| Array<{ created_at?: number; started_at?: number; duration_ms?: number; id: string }>
|
|
| undefined
|
|
) {
|
|
if (!moduleItems || !localModuleStates[moduleId]) return undefined
|
|
|
|
const idToFind =
|
|
localModuleStates[moduleId].selectedForloop ?? localModuleStates[moduleId].job_id
|
|
const index = moduleItems?.findIndex((item) => item.id === idToFind)
|
|
if (index === -1) {
|
|
return undefined
|
|
}
|
|
return index
|
|
}
|
|
|
|
function isJobFailure(jobId?: string, moduleId?: string) {
|
|
if (!moduleId) {
|
|
return rootJob?.type === 'CompletedJob' && rootJob?.['success'] === false
|
|
}
|
|
// if a jobId is provided, check the flow_jobs_success array for a specific job
|
|
if (localModuleStates[moduleId]?.flow_jobs_success && !!jobId) {
|
|
const index = localModuleStates[moduleId]?.flow_jobs?.indexOf(jobId)
|
|
if (index !== undefined && index >= 0) {
|
|
return localModuleStates[moduleId]?.flow_jobs_success?.[index] === false
|
|
}
|
|
}
|
|
return localModuleStates[moduleId]?.type === 'Failure'
|
|
}
|
|
</script>
|
|
|
|
{#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-primary" />
|
|
</Tooltip>
|
|
<div class="flex items-center gap-2 whitespace-nowrap">
|
|
<label for="showTimeline" class="text-xs text-primary hover:text-primary transition-colors"
|
|
>Show timeline</label
|
|
>
|
|
<div class="flex-shrink-0">
|
|
<input
|
|
type="checkbox"
|
|
name="showTimeline"
|
|
id="showTimeline"
|
|
bind:checked={showTimeline}
|
|
class="w-3 h-4 accent-primary -my-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 whitespace-nowrap">
|
|
<label
|
|
for="showResultsInputs"
|
|
class="text-xs text-primary hover:text-primary transition-colors"
|
|
>Show inputs/results</label
|
|
>
|
|
<div class="flex-shrink-0">
|
|
<input
|
|
type="checkbox"
|
|
name="showResultsInputs"
|
|
id="showResultsInputs"
|
|
bind:checked={showResultsInputs}
|
|
class="w-3 h-4 accent-primary -my-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onclick={toggleExpandAll}
|
|
class="text-xs text-primary hover:text-primary transition-colors flex items-center gap-2 min-w-24 justify-end"
|
|
>
|
|
{allExpanded ? 'Collapse All' : 'Expand All'}
|
|
{#if allExpanded}
|
|
<FoldVertical size={16} />
|
|
{:else}
|
|
<UnfoldVertical size={16} />
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
<ul class="w-full font-mono text-xs bg-surface-secondary list-none">
|
|
<!-- Flow entry -->
|
|
<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(getFlowStatus(rootJob), flowInfo?.hasErrors)}
|
|
|
|
<div class="text-xs text-left font-mono">
|
|
{mode === 'aiagent' ? 'AI Agent' : level == 0 ? 'Flow' : 'Subflow'}
|
|
{#if flowInfo?.label}
|
|
: {flowInfo.label}
|
|
{/if}
|
|
<span class="text-primary">{getStepProgress(rootJob, modules.length)}</span>
|
|
</div>
|
|
|
|
<div
|
|
class="min-w-min grow group"
|
|
bind:clientWidth={
|
|
() => timelineAvailableWidths[flowId] ?? 0,
|
|
(v) => (timelineAvailableWidths[flowId] = v)
|
|
}
|
|
>
|
|
{#if timelineItems && showTimeline && timelineMin != undefined && timelineTotal}
|
|
{@const moduleItems = [
|
|
{
|
|
started_at: rootJob?.started_at
|
|
? new Date(rootJob.started_at).getTime()
|
|
: undefined,
|
|
duration_ms: rootJob?.['duration_ms'] ?? timelineTotal,
|
|
id: flowId
|
|
}
|
|
]}
|
|
<FlowTimelineBar
|
|
total={timelineTotal}
|
|
min={timelineMin}
|
|
items={moduleItems}
|
|
now={timelineNow}
|
|
{timelinelWidth}
|
|
showZoomButtons={level > 0 && isExpanded(`flow-${flowId}`)}
|
|
onZoom={() => {
|
|
useRelativeTimeline = !useRelativeTimeline
|
|
}}
|
|
zoom={useRelativeTimeline ? 'in' : 'out'}
|
|
isJobFailure={(id) => isJobFailure(id)}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if flowInfo?.jobId}
|
|
<a
|
|
href={getJobLink(flowInfo.jobId)}
|
|
class="text-xs text-gray-400 hover:text-primary pl-1"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onclick={(e) => e.stopPropagation()}
|
|
>
|
|
<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}
|
|
|
|
<!-- 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}
|
|
|
|
{#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}
|
|
{@const jobId = localModuleStates[module.id]?.job_id}
|
|
{@const moduleItems = timelineItems?.[module.id]}
|
|
{@const branchChosen =
|
|
module.value.type === 'branchone'
|
|
? (localModuleStates[module.id]?.branchChosen ?? 0)
|
|
: undefined}
|
|
<FlowLogRow
|
|
id={module.id}
|
|
{isCollapsible}
|
|
{isRunning}
|
|
{isCurrent}
|
|
{isExpanded}
|
|
{toggleExpanded}
|
|
{select}
|
|
>
|
|
{#snippet label()}
|
|
<div
|
|
class={twMerge(
|
|
'flex items-center justify-between',
|
|
status === 'WaitingForPriorSteps' ||
|
|
status === 'WaitingForEvents' ||
|
|
status === 'WaitingForExecutor' ||
|
|
status === undefined
|
|
? 'opacity-50'
|
|
: ''
|
|
)}
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<!-- 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 text-left">
|
|
<b class="flex items-center gap-1">
|
|
{#if mode === 'aiagent'}
|
|
{#if module.summary}
|
|
Tool call: {module.summary}
|
|
{:else}
|
|
Message
|
|
{/if}
|
|
{:else}
|
|
{module.id}
|
|
{/if}
|
|
</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}
|
|
{#if module.summary && mode !== 'aiagent'}
|
|
: {module.summary}
|
|
{/if}
|
|
{#if hasEmptySubflowValue}
|
|
<span class="text-primary">
|
|
{#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 -my-2"
|
|
>
|
|
<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}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="min-w-min grow {isLeafStep ? 'mr-2' : 'mr-6'} min-h-2"
|
|
bind:clientWidth={
|
|
() => timelineAvailableWidths[module.id] ?? 0,
|
|
(v) => (timelineAvailableWidths[module.id] = v)
|
|
}
|
|
>
|
|
{#if timelineMin != undefined && timelineTotal && moduleItems && showTimeline}
|
|
<FlowTimelineBar
|
|
total={timelineTotal}
|
|
min={timelineMin}
|
|
items={moduleItems ?? []}
|
|
now={timelineNow}
|
|
{timelinelWidth}
|
|
onSelectIteration={(id) => {
|
|
if (
|
|
module.value.type !== 'forloopflow' &&
|
|
module.value.type !== 'whileloopflow'
|
|
) {
|
|
return
|
|
}
|
|
const index =
|
|
localModuleStates[module.id]?.flow_jobs?.indexOf(id) ?? undefined
|
|
if (index !== undefined) {
|
|
onSelectedIteration?.({
|
|
id,
|
|
index,
|
|
manuallySet: true,
|
|
moduleId: module.id
|
|
})
|
|
}
|
|
}}
|
|
showIterations={localModuleStates[module.id]?.flow_jobs}
|
|
selectedIndex={getSelectedIndex(module.id, moduleItems)}
|
|
idToIterationIndex={(id) => {
|
|
return localModuleStates[module.id]?.flow_jobs?.indexOf(id)
|
|
}}
|
|
isJobFailure={() => isJobFailure(undefined, module.id)}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if isLeafStep && 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}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#if isCollapsible && isExpanded(module.id, isRunning)}
|
|
{@const subflows = getSubflows(module)}
|
|
<div class="my-1 transition-all duration-200 ease-in-out border-l">
|
|
<!-- Show child steps if they exist -->
|
|
{#each subflows as subflow, idx}
|
|
{@const subflowJob = getSubflowJob(
|
|
module.id,
|
|
idx,
|
|
branchChosen,
|
|
module.value.type
|
|
)}
|
|
<div class="border-l mb-2">
|
|
<!-- Recursively render child steps using FlowLogViewer -->
|
|
<FlowLogViewer
|
|
modules={subflow.modules}
|
|
{localModuleStates}
|
|
rootJob={subflowJob}
|
|
{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}
|
|
{timelineNow}
|
|
{timelineMin}
|
|
{timelineTotal}
|
|
{timelineItems}
|
|
bind:timelineAvailableWidths
|
|
{timelinelWidth}
|
|
{showTimeline}
|
|
/>
|
|
</div>
|
|
{/each}
|
|
|
|
{#if subflows.length === 0}
|
|
{@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}
|
|
<!-- 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-primary 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>
|
|
{/snippet}
|
|
<ObjectViewer json={flowInfo.result} pureViewer={true} />
|
|
</FlowLogRow>
|
|
{/if}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
</FlowLogRow>
|
|
</ul>
|
|
{/if}
|
|
|
|
{#snippet flowIcon(status: FlowStatusModule['type'] | undefined, hasErrors: boolean | undefined)}
|
|
{@const colorClass = getStatusColor(status)}
|
|
<div class="relative flex items-center">
|
|
<BarsStaggered
|
|
size={10}
|
|
class={twMerge(colorClass, status === 'InProgress' ? 'animate-pulse' : '', 'flex-shrink-0')}
|
|
/>
|
|
{#if hasErrors && status !== 'Failure'}
|
|
<span
|
|
class="text-red-500 -ml-0.5 -mr-1.5"
|
|
title="A subflow or a step has failed but failure was skipped">!</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet stepIcon(
|
|
stepType: string | undefined,
|
|
status: FlowStatusModule['type'] | undefined,
|
|
hasErrors: boolean | undefined
|
|
)}
|
|
{@const colorClass = getStatusColor(status)}
|
|
{@const animationClass = status === 'InProgress' ? 'animate-pulse' : ''}
|
|
{@const classes = `${colorClass} ${animationClass} flex-shrink-0`}
|
|
<div class="relative flex items-center">
|
|
{#if stepType === 'flow'}
|
|
<BarsStaggered size={10} class={classes} />
|
|
{:else if stepType === 'forloopflow' || stepType === 'whileloopflow'}
|
|
<Repeat size={10} class={classes} />
|
|
{:else if stepType === 'branchall' || stepType === 'branchone'}
|
|
<GitBranch size={10} class={classes} />
|
|
{:else}
|
|
<Code strokeWidth={2.5} size={10} class={classes} />
|
|
{/if}
|
|
{#if hasErrors && status !== 'Failure'}
|
|
<span class="text-red-500 -ml-0.5 -mr-1.5" title="A subflow or a step has failed">!</span>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|