+ 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)
+ }
+
+
+{#if min && filteredItems.length > 0 && startItem?.started_at}
+
+ {#if showZoomButtons}
+
+
+
+ {:else if hasMoreIterations}
+
+
+ {#snippet text()}
+ Load previous iterations
+ {/snippet}
+
+ {:else}
+
+ {/if}
+
+ {#if waitingLen > 100 && startItem.created_at}
+
+
+
+
+ {:else if startItem?.started_at}
+
+ {/if}
+
+ {#if showIterations}
+
+ {#each filteredItems as item, i}
+ {#if item.started_at}
+ {@const position = calculateItemPosition(item)}
+ {@const opacity = getOverlapOpacity(item, filteredItems)}
+
+
+
+
+
+ {#snippet text()}
+ {`#${(idToIterationIndex?.(item.id) ?? 0) + 1}`}
+
+ {msToReadableTime(getLength(item), 1)}
+ {#if opacity < 1}
+
+ Overlapping
+ {/if}
+ {/snippet}
+
+ {/if}
+ {/each}
+ {:else}
+
+ {#if selectedItem?.started_at}
+ {@const position = calculateItemPosition(selectedItem)}
+
+
+
+ {#snippet text()}
+ {msToReadableTime(selectedLen, 1)}
+ {/snippet}
+
+ {/if}
+ {/if}
+
+ {#if selectedLen > 0}
+
{msToReadableTimeShort(selectedLen, 1)}
+ {/if}
+
+{/if}
diff --git a/frontend/src/lib/components/flows/map/FlowJobsMenu.svelte b/frontend/src/lib/components/flows/map/FlowJobsMenu.svelte
index 38df361584..39e232b7b9 100644
--- a/frontend/src/lib/components/flows/map/FlowJobsMenu.svelte
+++ b/frontend/src/lib/components/flows/map/FlowJobsMenu.svelte
@@ -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,
diff --git a/frontend/src/lib/components/flows/map/MapItem.svelte b/frontend/src/lib/components/flows/map/MapItem.svelte
index c6483e1fae..6f2070a8ba 100644
--- a/frontend/src/lib/components/flows/map/MapItem.svelte
+++ b/frontend/src/lib/components/flows/map/MapItem.svelte
@@ -118,7 +118,7 @@
{msToSec(duration_ms)}s
diff --git a/frontend/src/lib/components/meltComponents/Tooltip.svelte b/frontend/src/lib/components/meltComponents/Tooltip.svelte
index 71f41fad04..05046c4a4e 100644
--- a/frontend/src/lib/components/meltComponents/Tooltip.svelte
+++ b/frontend/src/lib/components/meltComponents/Tooltip.svelte
@@ -31,7 +31,7 @@
})
-
+
{#if !$$slots.default}
diff --git a/frontend/src/lib/timelineCompute.svelte.ts b/frontend/src/lib/timelineCompute.svelte.ts
new file mode 100644
index 0000000000..1708968bf2
--- /dev/null
+++ b/frontend/src/lib/timelineCompute.svelte.ts
@@ -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([])
+ #durationStatuses = $state>({})
+ #flowDone = $state(false)
+ #interval: number | undefined = undefined
+ #debounceInstance: { debounced: () => void; clearDebounce: () => void }
+
+ min = $state(undefined)
+ max = $state(undefined)
+ total = $state(undefined)
+ items = $state(undefined)
+ now = $state(getDbClockNow().getTime())
+
+ constructor(
+ flowModules: string[],
+ durationStatuses: Record,
+ 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,
+ 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
+ }
+ >
+ ): 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
+ }
+}
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
index b4cdbaf2ce..60a4615e84 100644
--- a/frontend/src/lib/utils.ts
+++ b/frontend/src/lib/utils.ts
@@ -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