From b0495b7133550ecbfd04c8cc90dcf9e9ca57f99e Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 15 Sep 2025 18:24:12 +0100 Subject: [PATCH] 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 --- .../components/FlowHistoryJobPicker.svelte | 3 + frontend/src/lib/components/FlowLogRow.svelte | 4 +- .../src/lib/components/FlowLogViewer.svelte | 350 ++++++++++++++---- .../components/FlowLogViewerWrapper.svelte | 82 +++- .../lib/components/FlowPreviewContent.svelte | 16 +- .../components/FlowStatusViewerInner.svelte | 5 + .../src/lib/components/FlowTimeline.svelte | 124 ++----- .../src/lib/components/FlowTimelineBar.svelte | 284 ++++++++++++++ .../components/flows/map/FlowJobsMenu.svelte | 8 +- .../lib/components/flows/map/MapItem.svelte | 2 +- .../components/meltComponents/Tooltip.svelte | 2 +- frontend/src/lib/timelineCompute.svelte.ts | 141 +++++++ frontend/src/lib/utils.ts | 22 ++ 13 files changed, 867 insertions(+), 176 deletions(-) create mode 100644 frontend/src/lib/components/FlowTimelineBar.svelte create mode 100644 frontend/src/lib/timelineCompute.svelte.ts diff --git a/frontend/src/lib/components/FlowHistoryJobPicker.svelte b/frontend/src/lib/components/FlowHistoryJobPicker.svelte index 293a4af24c..61e3765be8 100644 --- a/frontend/src/lib/components/FlowHistoryJobPicker.svelte +++ b/frontend/src/lib/components/FlowHistoryJobPicker.svelte @@ -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() diff --git a/frontend/src/lib/components/FlowLogRow.svelte b/frontend/src/lib/components/FlowLogRow.svelte index fe22c580d8..15eb19f38c 100644 --- a/frontend/src/lib/components/FlowLogRow.svelte +++ b/frontend/src/lib/components/FlowLogRow.svelte @@ -44,7 +44,7 @@ > - {#if isExpanded(id) || !isCollapsible} + {#if isExpanded(id, isRunning) || !isCollapsible}
{@render children()} diff --git a/frontend/src/lib/components/FlowLogViewer.svelte b/frontend/src/lib/components/FlowLogViewer.svelte index 9e1ba6c1bf..a22e0a4211 100644 --- a/frontend/src/lib/components/FlowLogViewer.svelte +++ b/frontend/src/lib/components/FlowLogViewer.svelte @@ -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 interface Props { modules: FlowModule[] localModuleStates: Record - rootJob: RootJobData - flowStatus: FlowStatusModule['type'] | undefined + rootJob: RootJobData | undefined expandedRows: Record 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 + timelinelWidth: number + showTimeline?: boolean + globalIterationBounds?: Record + 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>({}) + 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' + } {#if render} @@ -370,6 +492,20 @@ {/snippet} +
+ +
+ +
+