fix(frontend): add timeline to the flow log viewer (#6577)

* Fix flow time display

* Make compute timeline a separate component

* Add timeline to log viewer

* Add timeline for subflows

* remove debug log

* fix progresion display while running

* Handle loop iteration

* nit

* Display all iteration for loops

* Show total execution time for loop steps

* Show subflow timeline

* Do not hightlight selected iteration

* Add subflow duration and starting time

* Allow zoom on subflow timeline

* Show execution time

* Improve timeline layout

* nit

* hover effect

* add show timeline toggle

* reset log viewer state when job id changes

* Display history loader in flow preview

* handle branch one

* reset timeline on jobId change

* nit

* fix branch chosen default

* improve time display

* improve look v1

* improve look v2

* Allow loading of more iterations when limit is reached

* fix display

* Add tooltip

* Use popover to display durations

* allow select iteration from timeline

* remove debug log

* fix iteration to index for long loops

* select iteration based on id

* Use localModuleState to get current display job ids

* clean subflow job creation

* improve subflow fetching

* fix load more position

* improve parallele display

* clean

* Add color status

* remove unwanted change

* prevent toggle expand on click timeline

* fix expand running module

* make timeline optional

* prevent running flow be marked as error

* Fix width jump during execution

* fix typo

* nit

* Use a class for timeline computation

* nit
This commit is contained in:
Guilhem
2025-09-15 18:24:12 +01:00
committed by GitHub
parent c24c629317
commit b0495b7133
13 changed files with 867 additions and 176 deletions
@@ -9,9 +9,11 @@
export let path: string
export let selected: string | undefined = undefined
export let selectInitial: boolean = false
export let loading: boolean = false
const dispatch = createEventDispatcher()
async function loadInitial() {
loading = true
let jobs = await JobService.listJobs({
workspace: $workspaceStore!,
scriptPathExact: path,
@@ -26,6 +28,7 @@
} else {
dispatch('nohistory')
}
loading = false
}
$: $workspaceStore && loadInitial()
@@ -44,7 +44,7 @@
>
<button
class={twMerge(
'py-1 leading-tight w-full flex items-center justify-left text-xs text-tertiary hover:text-primary ',
'py-1 leading-tight w-full flex items-center justify-left text-xs text-tertiary hover:text-primary hover:bg-surface-hover ',
isCurrent(id) ? 'bg-surface-hover text-primary' : '',
className
)}
@@ -71,7 +71,7 @@
</div>
</button>
{#if isExpanded(id) || !isCollapsible}
{#if isExpanded(id, isRunning) || !isCollapsible}
<div class="my-1 transition-all duration-200 ease-in-out">
<div class="pl-4">
{@render children()}
+286 -64
View File
@@ -19,19 +19,19 @@
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 { GlobalIterationBounds, 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
flowStatus: FlowStatusModule['type'] | undefined
rootJob: RootJobData | undefined
expandedRows: Record<string, boolean>
allExpanded?: boolean
showResultsInputs?: boolean
@@ -41,7 +41,7 @@
render: boolean
level?: number
flowId: string
onSelectedIteration: (
onSelectedIteration?: (
detail:
| { id: string; index: number; manuallySet: true; moduleId: string }
| { manuallySet: false; moduleId: string }
@@ -52,13 +52,24 @@
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
globalIterationBounds?: Record<string, GlobalIterationBounds>
loadPreviousIterations?: (key: string, amount: number) => void
}
let {
modules,
localModuleStates,
rootJob,
flowStatus,
expandedRows,
allExpanded,
showResultsInputs,
@@ -74,7 +85,16 @@
mode = 'flow',
currentId,
navigationChain = $bindable(),
select
select,
timelineMin: timelineMinAbsolute,
timelineTotal: timelineTotalAbsolute,
timelineItems,
timelineNow,
timelineAvailableWidths = $bindable(),
timelinelWidth,
showTimeline = true,
globalIterationBounds,
loadPreviousIterations
}: Props = $props()
function getJobLink(jobId: string | undefined): string {
@@ -94,7 +114,8 @@
return status ? statusColors[status] : 'text-gray-400'
}
function getFlowStatus(job: RootJobData): FlowStatusModule['type'] | undefined {
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') {
@@ -104,8 +125,8 @@
}
}
function getStepProgress(job: RootJobData, totalSteps: number): string {
if (totalSteps === 0) return ''
function getStepProgress(job: RootJobData | undefined, totalSteps: number): string {
if (!job || totalSteps === 0) return ''
const stepWord = mode === 'aiagent' ? 'action' : 'step'
@@ -206,6 +227,7 @@
// Get flow info for display
const flowInfo = $derived.by(() => {
if (!rootJob) return undefined
const parentsWithErrors = findParentsOfErrors(modules)
return {
jobId: rootJob.id,
@@ -221,6 +243,8 @@
let subloopNavigationChains = $state<Record<string, NavigationChain>>({})
let useRelativeTimeline = $state(false)
function buildNavigationLinks(): NavigationChain {
const items: string[] = []
@@ -232,7 +256,12 @@
}
// Flow input (if exists and shown)
if (showResultsInputs && flowInfo.inputs && Object.keys(flowInfo.inputs).length > 0) {
if (
showResultsInputs &&
flowInfo &&
flowInfo.inputs &&
Object.keys(flowInfo.inputs).length > 0
) {
items.push(`flow-${flowId}-input`)
}
@@ -276,7 +305,12 @@
})
// Flow result (if exists and shown)
if (showResultsInputs && flowInfo.result !== undefined && rootJob.type === 'CompletedJob') {
if (
showResultsInputs &&
flowInfo &&
flowInfo.result !== undefined &&
rootJob?.type === 'CompletedJob'
) {
items.push(`flow-${flowId}-result`)
}
@@ -338,6 +372,15 @@
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]
@@ -347,18 +390,97 @@
flowId: `${module.id}-subflow-${i}`
})
}
// Add default branch for branchone
if (module.value.type === 'branchone') {
subflows.push({
modules: module.value.default,
label: 'default',
flowId: `${module.id}-subflow-default`
})
}
}
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}
@@ -370,6 +492,20 @@
{/snippet}
<Keyboard size={16} class="text-tertiary" />
</Tooltip>
<div class="flex items-center gap-2 whitespace-nowrap">
<label for="showTimeline" class="text-xs text-tertiary 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"
@@ -404,27 +540,60 @@
<FlowLogRow
id={`flow-${flowId}`}
isCollapsible={level > 0}
isRunning={rootJob.type === 'QueuedJob'}
isRunning={rootJob?.type === 'QueuedJob'}
{isCurrent}
{isExpanded}
{toggleExpanded}
class={rootJob.type === undefined ? 'opacity-50' : ''}
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)}
{@render flowIcon(getFlowStatus(rootJob), flowInfo?.hasErrors)}
<div class="text-xs text-left font-mono grow min-w-0">
<div class="text-xs text-left font-mono">
{mode === 'aiagent' ? 'AI Agent' : level == 0 ? 'Flow' : 'Subflow'}
{#if flowInfo.label}
{#if flowInfo?.label}
: {flowInfo.label}
{/if}
<span class="text-tertiary">{getStepProgress(rootJob, modules.length)}</span>
</div>
{#if flowInfo.jobId}
<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"
@@ -438,10 +607,10 @@
</div>
{/snippet}
{#if level === 0 || isExpanded(`flow-${flowId}`, rootJob.type === 'QueuedJob')}
{#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}
{#if flowInfo?.logs}
<LogViewer
content={flowInfo.logs}
jobId={flowInfo.jobId}
@@ -458,7 +627,7 @@
<!-- 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}
{#if showResultsInputs && flowInfo?.inputs && Object.keys(flowInfo.inputs).length > 0}
<FlowLogRow
id={`flow-${flowId}-input`}
isCollapsible={true}
@@ -491,6 +660,12 @@
{@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}
@@ -512,12 +687,12 @@
: ''
)}
>
<div class="flex items-center gap-2 grow min-w-0">
<div class="flex items-center gap-2">
<!-- Step icon -->
{@render stepIcon(
module.value.type,
status as FlowStatusModule['type'],
flowInfo.parentsWithErrors.has(module.id)
flowInfo?.parentsWithErrors.has(module.id)
)}
<div class="flex items-center gap-2">
@@ -559,7 +734,7 @@
</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"
class="text-xs font-mono font-medium inline-flex items-center -my-2"
>
<button onclick={(e) => e.stopPropagation()}>
<FlowJobsMenu
@@ -582,49 +757,83 @@
</div>
</div>
{#if isLeafStep}
{@const jobId = localModuleStates[module.id]?.job_id}
{#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>
<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 ?? []}
hasMoreIterations={globalIterationBounds?.[module.id] &&
(globalIterationBounds[module.id].iteration_from ?? 0) > 0}
now={timelineNow}
{timelinelWidth}
loadPreviousIterations={() => {
loadPreviousIterations?.(module.id, 20)
}}
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 args = localModuleStates[module.id]?.args}
{@const logs = localModuleStates[module.id]?.logs}
{@const result = localModuleStates[module.id]?.result}
{@const jobId = localModuleStates[module.id]?.job_id}
{@const subflows = getSubflows(module)}
<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'
}}
{#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}
flowStatus={localModuleStates[module.id]?.type}
{expandedRows}
{allExpanded}
{showResultsInputs}
@@ -640,11 +849,24 @@
{currentId}
bind:navigationChain={subloopNavigationChains[subflow.flowId]}
{select}
{timelineNow}
{timelineMin}
{timelineTotal}
{timelineItems}
bind:timelineAvailableWidths
{timelinelWidth}
{showTimeline}
{globalIterationBounds}
{loadPreviousIterations}
/>
</div>
{/each}
{#if getSubflows(module).length === 0}
{#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
@@ -738,7 +960,7 @@
{/if}
<!-- Flow result as last row entry -->
{#if showResultsInputs && flowInfo.result !== undefined && rootJob.type === 'CompletedJob'}
{#if showResultsInputs && flowInfo?.result !== undefined && rootJob?.type === 'CompletedJob'}
<FlowLogRow
id={`flow-${flowId}-result`}
isCollapsible={true}
@@ -764,7 +986,7 @@
</ul>
{/if}
{#snippet flowIcon(status: FlowStatusModule['type'] | undefined, hasErrors: boolean)}
{#snippet flowIcon(status: FlowStatusModule['type'] | undefined, hasErrors: boolean | undefined)}
{@const colorClass = getStatusColor(status)}
<div class="relative flex items-center">
<BarsStaggered
@@ -783,7 +1005,7 @@
{#snippet stepIcon(
stepType: string | undefined,
status: FlowStatusModule['type'] | undefined,
hasErrors: boolean
hasErrors: boolean | undefined
)}
{@const colorClass = getStatusColor(status)}
{@const animationClass = status === 'InProgress' ? 'animate-pulse' : ''}
@@ -1,32 +1,40 @@
<script lang="ts">
import type { Job } from '$lib/gen'
import type { GraphModuleState } from './graph'
import type { DurationStatus, GlobalIterationBounds, GraphModuleState } from './graph'
import FlowLogViewer from './FlowLogViewer.svelte'
import { untrack } from 'svelte'
import { TimelineCompute } from '$lib/timelineCompute.svelte'
import { onMount, untrack } from 'svelte'
import { ChangeTracker } from '$lib/svelte5Utils.svelte'
import { readFieldsRecursively } from '$lib/utils'
import type { NavigationChain } from '$lib/keyboardChain'
import OnChange from './common/OnChange.svelte'
interface Props {
job: Partial<Job>
localModuleStates: Record<string, GraphModuleState>
localDurationStatuses?: Record<string, DurationStatus>
globalIterationBounds?: Record<string, GlobalIterationBounds>
workspaceId: string | undefined
render: boolean
onSelectedIteration: (
onSelectedIteration?: (
detail:
| { id: string; index: number; manuallySet: true; moduleId: string }
| { manuallySet: false; moduleId: string }
) => Promise<void>
mode?: 'flow' | 'aiagent'
loadPreviousIterations?: (key: string, amount: number) => void
}
let {
job,
localModuleStates,
localDurationStatuses,
workspaceId,
render,
onSelectedIteration,
mode = 'flow'
mode = 'flow',
globalIterationBounds,
loadPreviousIterations
}: Props = $props()
// State for tracking expanded rows - using Record to allow explicit control
@@ -38,6 +46,26 @@
let currentId = $state<string | null>('flow-root')
let navigationChain = $state<NavigationChain>({})
// Timeline state
let timelineCompute = $state<TimelineCompute | undefined>(undefined)
onMount(() => {
timelineCompute = new TimelineCompute(
modules.map((m) => m.id),
localDurationStatuses ?? {},
job.type === 'CompletedJob'
)
return () => {
timelineCompute?.destroy()
}
})
// Derived timeline values
const timelineMin = $derived(timelineCompute?.min ?? undefined)
const timelineTotal = $derived(timelineCompute?.total ?? undefined)
const timelineItems = $derived(timelineCompute?.items ?? undefined)
const timelineNow = $derived(timelineCompute?.now ?? Date.now())
let moduleTracker = new ChangeTracker($state.snapshot(job.raw_flow?.modules ?? []))
$effect(() => {
readFieldsRecursively(job.raw_flow?.modules ?? [])
@@ -97,8 +125,45 @@
function select(id: string) {
currentId = id
}
let timelineAvailableWidths = $state<Record<string, number>>({})
let lastJobId: string | undefined = $state(job.id)
const timelinelWidth = $derived.by(() => {
const widths = Object.values(timelineAvailableWidths)
return widths.length > 0 ? Math.max(Math.min(...widths) - 12, 0) : 0
})
function updateJobId() {
if (job.id !== lastJobId) {
lastJobId = job.id
navigationChain = {}
timelineAvailableWidths = {}
currentId = 'flow-root'
showResultsInputs = true
timelineCompute?.reset()
}
}
$effect.pre(() => {
job.id
untrack(() => {
job.id && updateJobId()
})
})
</script>
<OnChange
key={localDurationStatuses}
onChange={() => {
timelineCompute?.updateInputs(
modules.map((m) => m.id),
localDurationStatuses ?? {},
job.type === 'CompletedJob'
)
}}
/>
<div
class="w-full rounded-md overflow-hidden border focus:border-gray-400 dark:focus:border-gray-400"
role="tree"
@@ -119,10 +184,17 @@
{render}
{getSelectedIteration}
flowId="root"
flowStatus={undefined}
{mode}
{currentId}
bind:navigationChain
{select}
{timelineMin}
{timelineTotal}
{timelineItems}
{timelineNow}
bind:timelineAvailableWidths
{timelinelWidth}
{globalIterationBounds}
{loadPreviousIterations}
/>
</div>
@@ -18,7 +18,15 @@
import SchemaFormWithArgPicker from './SchemaFormWithArgPicker.svelte'
import FlowStatusViewer from '../components/FlowStatusViewer.svelte'
import FlowProgressBar from './flows/FlowProgressBar.svelte'
import { AlertTriangle, ArrowRight, CornerDownLeft, Play, RefreshCw, X } from 'lucide-svelte'
import {
AlertTriangle,
ArrowRight,
CornerDownLeft,
Loader2,
Play,
RefreshCw,
X
} from 'lucide-svelte'
import { emptyString, sendUserToast, type StateStore } from '$lib/utils'
import { dfs } from './flows/dfs'
import { sliceModules } from './flows/flowStateUtils.svelte'
@@ -108,6 +116,7 @@
let currentJobId: string | undefined = $state(undefined)
let stepHistoryLoader = getStepHistoryLoaderContext()
let flowProgressBar: FlowProgressBar | undefined = $state(undefined)
let loadingHistory = $state(false)
function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow {
const previewFlow = aiChatManager.flowAiChatHelpers?.getPreviewFlow()
@@ -546,6 +555,7 @@
currentJobId = undefined
}}
path={$initialPathStore == '' ? $pathStore : $initialPathStore}
bind:loading={loadingHistory}
/>
{/if}
</div>
@@ -586,6 +596,10 @@
{render}
{customUi}
/>
{:else if loadingHistory}
<div class="italic text-tertiary h-full grow mx-auto flex flex-row items-center gap-2">
<Loader2 class="animate-spin" /> <span> Loading history... </span>
</div>
{:else}
<div class="italic text-tertiary h-full grow"> Flow status will be displayed here </div>
{/if}
@@ -1546,9 +1546,14 @@
<FlowLogViewerWrapper
{job}
{localModuleStates}
{localDurationStatuses}
{workspaceId}
{render}
{onSelectedIteration}
{globalIterationBounds}
loadPreviousIterations={(key, amount) => {
loadPreviousIters(key, amount)
}}
/>
</div>
{#if selected == 'assets' && render}
+26 -98
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import { debounce, displayDate, msToSec, readFieldsRecursively } from '$lib/utils'
import { onDestroy, untrack } from 'svelte'
import { getDbClockNow } from '$lib/forLater'
import { displayDate, msToSec } from '$lib/utils'
import { Loader2 } from 'lucide-svelte'
import TimelineBar from './TimelineBar.svelte'
import WaitTimeWarning from './common/waitTimeWarning/WaitTimeWarning.svelte'
import type { GlobalIterationBounds } from './graph'
import { TimelineCompute } from '$lib/timelineCompute.svelte'
import { onMount } from 'svelte'
import OnChange from './common/OnChange.svelte'
interface Props {
selfWaitTime?: number | undefined
@@ -34,108 +35,35 @@
globalIterationBounds
}: Props = $props()
let min: undefined | number = $state(undefined)
let max: undefined | number = $state(undefined)
let total: number | undefined = $state(undefined)
let timelineCompute = $state<TimelineCompute | undefined>(undefined)
let items:
| Record<
string,
Array<{ created_at?: number; started_at?: number; duration_ms?: number; id: string }>
>
| undefined = $state(undefined)
let { debounced, clearDebounce } = debounce(() => computeItems(durationStatuses), 30)
$effect(() => {
readFieldsRecursively(durationStatuses)
flowDone != undefined && durationStatuses && untrack(() => debounced())
// Initialize timeline compute when we have duration statuses
onMount(() => {
timelineCompute = new TimelineCompute(flowModules, durationStatuses, flowDone)
return () => {
timelineCompute?.destroy()
}
})
// Derived timeline values
const min = $derived(timelineCompute?.min ?? undefined)
const max = $derived(timelineCompute?.max ?? undefined)
const total = $derived(timelineCompute?.total ?? undefined)
const items = $derived(timelineCompute?.items ?? undefined)
const now = $derived(timelineCompute?.now ?? Date.now())
export function reset() {
min = undefined
max = undefined
items = computeItems(durationStatuses)
timelineCompute?.reset()
}
function computeItems(
durationStatuses: Record<
string,
{
byJob: Record<string, { created_at?: number; started_at?: number; duration_ms?: number }>
}
>
): any {
let nmin: undefined | number = undefined
let nmax: undefined | number = undefined
let isStillRunning = false
let cnt = 0
let nitems = {}
Object.entries(durationStatuses).forEach(([k, o]) => {
Object.values(o.byJob).forEach((v) => {
cnt++
if (v.started_at) {
if (!nmin) {
nmin = v.started_at
} else {
nmin = Math.min(nmin, v.started_at)
}
}
if (!flowDone && v.duration_ms == undefined) {
isStillRunning = true
}
if (!isStillRunning) {
if (v.started_at && v.duration_ms != undefined) {
let lmax = v.started_at + v.duration_ms
if (!nmax) {
nmax = lmax
} else {
nmax = Math.max(nmax, lmax)
}
}
}
})
let arr = Object.entries(o.byJob).map(([k, v]) => ({ ...v, id: k }))
arr.sort((x, y) => {
if (!x.started_at) {
return -1
} else if (!y.started_at) {
return 1
} else {
return x.started_at - y.started_at
}
})
nitems[k] = arr
})
items = nitems
min = nmin
max = isStillRunning || (cnt < flowModules.length && !flowDone) ? undefined : nmax
if (max && min) {
total = max - min
total = Math.max(total, 2000)
}
}
let now = $state(getDbClockNow().getTime())
let interval = setInterval((x) => {
if (!max) {
now = getDbClockNow().getTime()
}
if (min && (!max || total == undefined)) {
total = max ? max - min : Math.max(now - min, 2000)
}
}, 30)
onDestroy(() => {
interval && clearInterval(interval)
clearDebounce()
})
</script>
<OnChange
key={durationStatuses}
onChange={() => {
timelineCompute?.updateInputs(flowModules, durationStatuses, flowDone)
}}
/>
{#if items}
<div class="divide-y border-b">
<div class="px-2 py-2 grid grid-cols-12 w-full"
@@ -0,0 +1,284 @@
<script lang="ts">
import { msToReadableTime, msToReadableTimeShort } from '$lib/utils'
import { ZoomIn, ZoomOut } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { Tooltip } from './meltComponents'
interface TimelineItem {
created_at?: number
started_at?: number
duration_ms?: number
id: string
}
interface Props {
total: number
min: number | undefined
items: TimelineItem[]
selectedIndex?: number
now: number
timelinelWidth: number
showZoomButtons?: boolean
onZoom?: () => void
zoom?: 'in' | 'out'
hasMoreIterations?: boolean
loadPreviousIterations?: () => void
onSelectIteration?: (id: string) => void
idToIterationIndex?: (id: string) => number | undefined
showIterations?: string[]
isJobFailure?: (id: string) => boolean
}
let {
total,
min,
items,
selectedIndex,
now,
timelinelWidth,
showZoomButtons = false,
onZoom,
zoom = 'in',
hasMoreIterations,
loadPreviousIterations,
onSelectIteration,
idToIterationIndex,
showIterations,
isJobFailure
}: Props = $props()
function getLength(item: TimelineItem): number {
if (!item?.started_at) return 0
return item.duration_ms ?? now - item.started_at
}
function isRunning(item: TimelineItem): boolean {
return item.started_at !== undefined && item.duration_ms === undefined
}
const filteredItems = $derived(
showIterations ? items.filter((item) => showIterations.includes(item.id)) : items
)
let selectedItem = $derived(
selectedIndex && selectedIndex >= 0 ? filteredItems[selectedIndex] : filteredItems[0]
)
let startItem = $derived(showIterations ? filteredItems[0] : selectedItem)
// Calculate total execution time for multiple filteredItems
function calculateTotalExecutionTime(): number {
let earliestStart: number | undefined
let latestEnd = 0
for (const item of filteredItems) {
if (item.started_at) {
// Track earliest start
if (!earliestStart || item.started_at < earliestStart) {
earliestStart = item.started_at
}
// Track latest end
const itemEnd = item.duration_ms ? item.started_at + item.duration_ms : now
latestEnd = Math.max(latestEnd, itemEnd)
}
}
return earliestStart ? latestEnd - earliestStart : 0
}
let selectedLen = $derived(
// If selectedIteration is set, it means we are in a loop and we are selecting an iteration
showIterations ? calculateTotalExecutionTime() : getLength(selectedItem)
)
const waitingLen = $derived(
startItem?.created_at
? startItem.started_at
? startItem.started_at - startItem.created_at
: startItem.duration_ms
? 0
: now - startItem.created_at
: 0
)
function calculateItemPosition(item: TimelineItem): { left: number; width: number } {
if (!item.started_at || !min) return { left: 0, width: 0 }
const startOffset = item.started_at - min!
const duration = getLength(item)
const leftPercent = (startOffset / total) * 100
const widthPercent = (duration / total) * 100
return { left: leftPercent, width: widthPercent }
}
function getOverlapOpacity(item: TimelineItem, allItems: TimelineItem[]): number {
if (!item.started_at) return 1
const itemEnd = item.duration_ms ? item.started_at + item.duration_ms : now
let overlapCount = 0
for (const otherItem of allItems) {
if (otherItem.id === item.id || !otherItem.started_at) continue
const otherEnd = otherItem.duration_ms ? otherItem.started_at + otherItem.duration_ms : now
// Check if time ranges overlap
if (item.started_at < otherEnd && otherItem.started_at < itemEnd) {
overlapCount++
}
}
// Base opacity of 1, reduce by 0.2 for each overlap, minimum 0.3
return Math.max(0.3, 1 - overlapCount * 0.2)
}
</script>
{#if min && filteredItems.length > 0 && startItem?.started_at}
<div
class="flex items-center gap-2 ml-auto min-w-96 max-w-[1000px] h-4 group"
style="width: {timelinelWidth}px"
>
{#if showZoomButtons}
<div class="w-24 flex items-center justify-end">
<button
onclick={(e) => {
e.stopPropagation()
onZoom?.()
}}
class="hover:text-primary hover:bg-surface p-1 -my-1 rounded-md"
>
{#if zoom === 'in'}
<ZoomOut size={12} />
{:else}
<ZoomIn size={12} />
{/if}
</button>
</div>
{:else if hasMoreIterations}
<Tooltip
class="hover:text-primary hover:bg-surface p-1 -my-1 w-24 rounded-md flex items-center justify-center"
openDelay={100}
>
<button
class="text-2xs text-primary whitespace-nowrap"
onclick={(e) => {
e.stopPropagation()
loadPreviousIterations?.()
}}
>
load more...
</button>
{#snippet text()}
Load previous iterations
{/snippet}
</Tooltip>
{:else}
<div class="w-24"></div>
{/if}
<div
class="flex-1 h-1 bg-gray-300 dark:bg-gray-800 rounded-sm overflow-hidden group-hover:h-full transition-all duration-100 relative"
>
{#if waitingLen > 100 && startItem.created_at}
<div
style="width: {((startItem.created_at - min) / total) * 100}%"
class="h-full absolute left-0 top-0"
>
</div>
<div
style="left: {((startItem.created_at - min) / total) * 100}%; width: {(waitingLen /
total) *
100}%"
class="h-full absolute top-0 bg-gray-300 dark:bg-gray-600"
title={msToReadableTime(waitingLen, 1)}
>
</div>
{:else if startItem?.started_at}
<div
style="width: {((startItem.started_at - min) / total) * 100}%"
class="h-full absolute left-0 top-0"
></div>
{/if}
{#if showIterations}
<!-- All iterations with absolute positioning -->
{#each filteredItems as item, i}
{#if item.started_at}
{@const position = calculateItemPosition(item)}
{@const opacity = getOverlapOpacity(item, filteredItems)}
<Tooltip
style="left: {position.left}%; width: {position.width}%"
class="h-full absolute top-0"
openDelay={100}
>
<!-- svelte-ignore a11y_consider_explicit_label -->
<div class="relative w-full h-full">
<button
class={twMerge(
'w-full h-full hover:outline outline-1 outline-blue-800 dark:outline-blue-300 -outline-offset-1 rounded-sm block transition-opacity duration-200',
isRunning(item)
? 'bg-blue-400'
: isJobFailure?.(item.id)
? ' bg-red-500'
: ' bg-blue-500',
i > 0 ? 'border-l border-gray-300 dark:border-gray-800 ' : '',
i === selectedIndex ? 'outline' : ''
)}
style="opacity: {opacity}"
onclick={(e) => {
e.stopPropagation()
onSelectIteration?.(item.id)
}}
>
</button>
</div>
{#snippet text()}
{`#${(idToIterationIndex?.(item.id) ?? 0) + 1}`}
<br />
{msToReadableTime(getLength(item), 1)}
{#if opacity < 1}
<br />
<span class="text-xs opacity-75">Overlapping</span>
{/if}
{/snippet}
</Tooltip>
{/if}
{/each}
{:else}
<!-- Single item case or inside a loop -->
{#if selectedItem?.started_at}
{@const position = calculateItemPosition(selectedItem)}
<Tooltip
class="h-full absolute top-0"
style="left: {position.left}%; width: {position.width}%"
openDelay={100}
>
<!-- svelte-ignore a11y_consider_explicit_label -->
<button
class={twMerge(
'block w-full h-full hover:outline outline-1 outline-white -outline-offset-1 rounded-sm',
isRunning(selectedItem)
? 'bg-blue-400'
: isJobFailure?.(selectedItem.id)
? ' bg-red-500'
: ' bg-blue-500'
)}
onclick={(e) => {
e.stopPropagation()
}}
></button>
{#snippet text()}
{msToReadableTime(selectedLen, 1)}
{/snippet}
</Tooltip>
{/if}
{/if}
</div>
{#if selectedLen > 0}
<span
class="text-2xs text-tertiary font-mono font-normal w-10 truncate"
title={msToReadableTime(selectedLen, 1)}>{msToReadableTimeShort(selectedLen, 1)}</span
>
{/if}
</div>
{/if}
@@ -14,7 +14,7 @@
flowJobsSuccess: (boolean | undefined)[] | undefined
selected: number
selectedManually: boolean | undefined
onSelectedIteration: onSelectedIteration
onSelectedIteration?: onSelectedIteration
showIcon?: boolean
}
@@ -39,7 +39,7 @@
filter > 0
) {
event.preventDefault()
onSelectedIteration({
onSelectedIteration?.({
index: filter - 1,
id: flowJobs[filter - 1],
manuallySet: true,
@@ -84,7 +84,7 @@
onmouseleave={() => (buttonHover = false)}
onclick={(e) => {
buttonHover = false
onSelectedIteration({ manuallySet: false, moduleId: moduleId })
onSelectedIteration?.({ manuallySet: false, moduleId: moduleId })
}}
>
{#if buttonHover}
@@ -155,7 +155,7 @@
items[idx].index == selected ? 'bg-surface-selected' : ''
)}
onClick={() => {
onSelectedIteration({
onSelectedIteration?.({
moduleId: moduleId,
index: items[idx].index,
id: items[idx].id,
@@ -118,7 +118,7 @@
<div
class={twMerge(
'absolute z-10 right-0 -top-4 center-center text-tertiary text-2xs',
editMode ? 'text-gray-400 dark:text-gray-500 text-2xs font-normal mr-2' : ''
editMode ? 'text-gray-400 dark:text-gray-500 text-2xs font-normal mr-2 right-10' : ''
)}
>
{msToSec(duration_ms)}s
@@ -31,7 +31,7 @@
})
</script>
<span class={$$props.class} use:melt={$trigger}>
<span class={$$props.class} style={$$props.style} use:melt={$trigger}>
<slot />
</span>
{#if !$$slots.default}
+141
View File
@@ -0,0 +1,141 @@
import { debounce, readFieldsRecursively } from '$lib/utils'
import { untrack } from 'svelte'
import { getDbClockNow } from '$lib/forLater'
import type { DurationStatus } from './components/graph/model'
export type TimelineItems = Record<
string,
Array<{ created_at?: number; started_at?: number; duration_ms?: number; id: string }>
>
export class TimelineCompute {
#flowModules = $state<string[]>([])
#durationStatuses = $state<Record<string, DurationStatus>>({})
#flowDone = $state(false)
#interval: number | undefined = undefined
#debounceInstance: { debounced: () => void; clearDebounce: () => void }
min = $state<number | undefined>(undefined)
max = $state<number | undefined>(undefined)
total = $state<number | undefined>(undefined)
items = $state<TimelineItems | undefined>(undefined)
now = $state<number>(getDbClockNow().getTime())
constructor(
flowModules: string[],
durationStatuses: Record<string, DurationStatus>,
flowDone: boolean = false
) {
this.#flowModules = flowModules
this.#durationStatuses = durationStatuses
this.#flowDone = flowDone
this.#debounceInstance = debounce(() => this.computeItems(this.#durationStatuses), 30)
// Set up reactivity using $effect
$effect(() => {
readFieldsRecursively(this.#durationStatuses)
this.#flowDone != undefined &&
this.#durationStatuses &&
untrack(() => this.#debounceInstance.debounced())
})
// Set up interval for updating now and total for running jobs
this.#interval = setInterval(() => {
if (!this.max) {
this.now = getDbClockNow().getTime()
}
if (this.min && (!this.max || this.total == undefined)) {
this.total = this.max ? this.max - this.min : Math.max(this.now - this.min, 2000)
}
}, 30)
}
reset() {
this.min = undefined
this.max = undefined
this.items = this.computeItems(this.#durationStatuses)
}
updateInputs(
flowModules: string[],
durationStatuses: Record<string, DurationStatus>,
flowDone: boolean = false
) {
this.#flowModules = flowModules
this.#durationStatuses = durationStatuses
this.#flowDone = flowDone
}
destroy() {
if (this.#interval) {
clearInterval(this.#interval)
}
this.#debounceInstance.clearDebounce()
}
private computeItems(
durationStatuses: Record<
string,
{
byJob: Record<string, { created_at?: number; started_at?: number; duration_ms?: number }>
}
>
): TimelineItems {
let nmin: undefined | number = undefined
let nmax: undefined | number = undefined
let isStillRunning = false
let cnt = 0
let nitems: TimelineItems = {}
Object.entries(durationStatuses).forEach(([k, o]) => {
Object.values(o.byJob).forEach((v) => {
cnt++
if (v.started_at) {
if (!nmin) {
nmin = v.started_at
} else {
nmin = Math.min(nmin, v.started_at)
}
}
if (!this.#flowDone && v.duration_ms == undefined) {
isStillRunning = true
}
if (!isStillRunning) {
if (v.started_at && v.duration_ms != undefined) {
let lmax = v.started_at + v.duration_ms
if (!nmax) {
nmax = lmax
} else {
nmax = Math.max(nmax, lmax)
}
}
}
})
let arr = Object.entries(o.byJob).map(([k, v]) => ({ ...v, id: k }))
arr.sort((x, y) => {
if (!x.started_at) {
return -1
} else if (!y.started_at) {
return 1
} else {
return x.started_at - y.started_at
}
})
nitems[k] = arr
})
this.items = nitems
this.min = nmin
this.max =
isStillRunning || (cnt < this.#flowModules.length && !this.#flowDone) ? undefined : nmax
if (this.max && this.min) {
this.total = this.max - this.min
this.total = Math.max(this.total, 2000)
}
return nitems
}
}
+22
View File
@@ -248,6 +248,28 @@ export function msToReadableTime(ms: number | undefined, maximumFractionDigits?:
}
}
export function msToReadableTimeShort(
ms: number | undefined,
maximumFractionDigits?: number
): string {
if (ms === undefined) return '?'
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) {
return `${days}d`
} else if (hours > 0) {
return `${hours}h`
} else if (minutes > 0) {
return `${minutes}m`
} else {
return `${msToSec(ms, maximumFractionDigits)}s`
}
}
export function getToday() {
var today = new Date()
return today