mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 00:01:55 +00:00
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:
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user