diff --git a/src/lib/components/lines-and-dots/end-time-interval.svelte b/src/lib/components/lines-and-dots/end-time-interval.svelte index 6f92d452ea..aa7ca0776b 100644 --- a/src/lib/components/lines-and-dots/end-time-interval.svelte +++ b/src/lib/components/lines-and-dots/end-time-interval.svelte @@ -9,7 +9,7 @@ export let workflow: WorkflowExecution; export let startTime: string | Timestamp; - let currentTime = Date.now(); + export let currentTime = Date.now(); const rightNow = () => { currentTime = Date.now(); diff --git a/src/lib/components/lines-and-dots/svg/dot.svelte b/src/lib/components/lines-and-dots/svg/dot.svelte index eed730914a..7c65fe233a 100644 --- a/src/lib/components/lines-and-dots/svg/dot.svelte +++ b/src/lib/components/lines-and-dots/svg/dot.svelte @@ -10,7 +10,7 @@ type Props = { point: [number, number]; category?: string; - classification?: string; + classification?: string | null; r?: number; icon?: TimelineIconName; strokeWidth?: number; diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph-row-rendering.test.ts b/src/lib/components/lines-and-dots/svg/timeline-graph-row-rendering.test.ts index 6cf1217a85..94049d266e 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph-row-rendering.test.ts +++ b/src/lib/components/lines-and-dots/svg/timeline-graph-row-rendering.test.ts @@ -371,7 +371,7 @@ describe('category → dot icon selection', () => { it('paused state overrides to "pause" icon (second dot)', () => { const pauseTime = '2024-01-01T00:00:10Z'; // index !== 0 && pauseTime → 'pause' - const index = 1; + const index: number = 1; const icon = pauseTime && index !== 0 ? 'pause' : 'activity'; expect(icon).toBe('pause'); }); diff --git a/src/lib/components/lines-and-dots/svg/graph-widget.svelte b/src/lib/components/lines-and-dots/timeline-graph/graph-widget.svelte similarity index 84% rename from src/lib/components/lines-and-dots/svg/graph-widget.svelte rename to src/lib/components/lines-and-dots/timeline-graph/graph-widget.svelte index b2f898db0e..8fcdcfcd52 100644 --- a/src/lib/components/lines-and-dots/svg/graph-widget.svelte +++ b/src/lib/components/lines-and-dots/timeline-graph/graph-widget.svelte @@ -44,12 +44,14 @@ {#await getWorkflowAndEventHistory() then { workflow, history }} -
- -
+ {#if workflow} +
+ +
+ {/if} {/await} diff --git a/src/lib/components/lines-and-dots/svg/group-details-row.svelte b/src/lib/components/lines-and-dots/timeline-graph/group-details-row.svelte similarity index 88% rename from src/lib/components/lines-and-dots/svg/group-details-row.svelte rename to src/lib/components/lines-and-dots/timeline-graph/group-details-row.svelte index 7380fbfcef..14ad57d9f6 100644 --- a/src/lib/components/lines-and-dots/svg/group-details-row.svelte +++ b/src/lib/components/lines-and-dots/timeline-graph/group-details-row.svelte @@ -67,7 +67,7 @@ if (group?.pendingActivity) { if (group.pendingActivity.paused) { status = translate('workflows.paused'); - } else if (group.pendingActivity.attempt > 1) { + } else if ((group.pendingActivity.attempt ?? 0) > 1) { status = translate('events.event-classification.retrying'); } else { status = translate('events.event-classification.pending'); @@ -114,15 +114,17 @@
Child Workflow
{#key group.eventList.length} - + {#if childWorkflowStartedEvent.attributes.workflowExecution?.workflowId} + + {/if} {/key}
{/if} diff --git a/src/lib/components/lines-and-dots/svg/timeline-axis.svelte b/src/lib/components/lines-and-dots/timeline-graph/timeline-axis.svelte similarity index 71% rename from src/lib/components/lines-and-dots/svg/timeline-axis.svelte rename to src/lib/components/lines-and-dots/timeline-graph/timeline-axis.svelte index 201db002c1..64955bed01 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-axis.svelte +++ b/src/lib/components/lines-and-dots/timeline-graph/timeline-axis.svelte @@ -3,22 +3,25 @@ import { formatDistanceAbbreviated } from '$lib/utilities/format-time'; import { TimelineConfig } from '../constants'; + import Line from '../svg/line.svelte'; - import Line from './line.svelte'; + import type { TimelineScale } from './timeline-scale.svelte'; type Props = { x1: number; x2: number; + gutter: number; timelineHeight: number; startTime: string | Timestamp; - duration: number; + scale: TimelineScale; }; let { x1 = 0, x2 = 1000, + gutter = 0, timelineHeight = 1000, startTime, - duration, + scale, }: Props = $props(); const { radius } = TimelineConfig; @@ -26,6 +29,10 @@ const distance = $derived(x2 - x1); const tickDistance = $derived(distance / ticks); + + const startMs = $derived(scale.unproject(x1 - gutter)); + const endMs = $derived(scale.unproject(x2 - gutter)); + const includeMilliseconds = $derived((endMs - startMs) / ticks < 1000); {formatDistanceAbbreviated({ start: startTime, - end: new Date( - new Date(startTime.toString()).getTime() + (duration / ticks) * i, - ), - includeMilliseconds: duration / ticks < 1000, + end: new Date(scale.unproject(tickX - gutter)), + includeMilliseconds, })} {/if} diff --git a/src/lib/components/lines-and-dots/timeline-graph/timeline-collapsed-layer.svelte b/src/lib/components/lines-and-dots/timeline-graph/timeline-collapsed-layer.svelte new file mode 100644 index 0000000000..bd70b028e1 --- /dev/null +++ b/src/lib/components/lines-and-dots/timeline-graph/timeline-collapsed-layer.svelte @@ -0,0 +1,183 @@ + + +{#each collapsibleSegments as seg (seg.key)} + {@const labelX = (seg.startPx + seg.endPx) / 2} + {@const labelY = timelineHeight + radius * 2} + {@const distance = formatDistanceAbbreviated({ + start: new Date(seg.startTimeMs), + end: new Date(seg.endTimeMs), + })} + {#if seg.isCollapsed} + {@const half = Math.min(ZIGZAG_HALF_WIDTH, (seg.endPx - seg.startPx) / 4)} + + {@render marker(labelX, height, { + text: translate('workflows.show-idle-time-segment', { + distance, + }), + show: activeSegmentKey === seg.key, + })} + {@render marker(labelX, timelineHeight)} + + {distance} + + {/if} + {#if !readOnly} + (activeSegmentKey = seg.key)} + onmouseleave={() => (activeSegmentKey = null)} + onfocus={() => (activeSegmentKey = seg.key)} + onblur={() => (activeSegmentKey = null)} + onclick={() => handleToggle(seg.key)} + onkeydown={(e) => handleKeydown(e, seg.key)} + /> + {/if} +{/each} + +{#snippet marker( + cx: number, + cy: number, + tooltip?: { + text?: string; + show?: boolean; + }, +)} + {@const iconSize = radius * 2} + + + + + + +{/snippet} + + diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte b/src/lib/components/lines-and-dots/timeline-graph/timeline-graph-row.svelte similarity index 75% rename from src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte rename to src/lib/components/lines-and-dots/timeline-graph/timeline-graph-row.svelte index 4b32262d52..c74f29667f 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte +++ b/src/lib/components/lines-and-dots/timeline-graph/timeline-graph-row.svelte @@ -1,10 +1,7 @@ -{#if hovering} +{#if hovering || focused} {@const multiEventHoverWidth = points.length >= 2 && points[points.length - 1] - points[0] + radius * 3} {@const hoverWidth = @@ -233,9 +195,11 @@ tabindex="0" aria-label={accessibleName} onclick={onClick} - onkeypress={onClick} + onkeydown={onKeydown} onmouseenter={onMouseEnter} onmouseleave={onMouseLeave} + onfocus={onFocus} + onblur={onBlur} class="relative cursor-pointer" {height} > @@ -252,7 +216,7 @@ pointer-events="all" /> {/if} - {#each points as x, index} + {#each points as x, index (index)} {@const nextPoint = points[index + 1]} {@const showText = textIndex === index} {#if nextPoint} @@ -274,7 +238,7 @@ startPoint={[x, y]} endPoint={[canvasWidth - gutter, y]} category={pendingActivity - ? pendingActivity.attempt > 1 + ? (pendingActivity.attempt ?? 0) > 1 ? 'retry' : 'pending' : group.category} diff --git a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte b/src/lib/components/lines-and-dots/timeline-graph/timeline-graph.svelte similarity index 91% rename from src/lib/components/lines-and-dots/svg/timeline-graph.svelte rename to src/lib/components/lines-and-dots/timeline-graph/timeline-graph.svelte index b89eacbdf0..4902d4fdfe 100644 --- a/src/lib/components/lines-and-dots/svg/timeline-graph.svelte +++ b/src/lib/components/lines-and-dots/timeline-graph/timeline-graph.svelte @@ -6,22 +6,27 @@ import { eventStatusFilter } from '$lib/stores/filters'; import type { WorkflowExecution } from '$lib/types/workflows'; import { isWorkflowDelayed } from '$lib/utilities/delayed-workflows'; + import { type ValidTime, validTimeToDate } from '$lib/utilities/format-time'; import { getFailedOrPendingGroups } from '$lib/utilities/get-failed-or-pending'; import { TimelineConfig } from '../constants'; import EndTimeInterval from '../end-time-interval.svelte'; + import Line from '../svg/line.svelte'; + import TimelineIconDefs from '../svg/timeline-icon-defs.svelte'; import { getDescStart, getPendingBlockY, getRowY, getTotalForY, - } from './timeline-positioning'; + } from '../svg/timeline-positioning'; import GroupDetailsRow from './group-details-row.svelte'; - import Line from './line.svelte'; import TimelineAxis from './timeline-axis.svelte'; + import TimelineCollapsedLayer from './timeline-collapsed-layer.svelte'; import TimelineGraphRow from './timeline-graph-row.svelte'; - import TimelineIconDefs from './timeline-icon-defs.svelte'; + import { TimelineScale } from './timeline-scale.svelte'; + import { Timeline } from './timeline.svelte'; + import { Viewport } from './viewport.svelte'; import WorkflowRow from './workflow-row.svelte'; interface Props { @@ -40,6 +45,7 @@ totalExpectedEvents?: number; descMinId?: number; panelHeight?: number; + onTimelineInit?: (timeline: Timeline) => void; } let { @@ -56,6 +62,7 @@ totalExpectedEvents = 0, descMinId = 0, panelHeight = $bindable(0), + onTimelineInit, }: Props = $props(); const { height, gutter, radius } = TimelineConfig; @@ -144,6 +151,42 @@ }; }); + const timelineWidth = $derived(canvasWidth - 2 * gutter); + + let nowMs = $state(Date.now()); + + const timeline = new Timeline({ + getFullEventHistory: () => $fullEventHistory, + getWorkflow: () => workflow, + getEventGroups: () => groups, + getCurrentTimeMs: () => nowMs, + }); + + const viewport = new Viewport({ startTimeMs: 0, endTimeMs: 0 }); + const scale = new TimelineScale({ timeline, viewport }); + + $effect(() => { + onTimelineInit?.(timeline); + }); + + $effect(() => { + viewport.setSize(timelineWidth, 0); + }); + + const projectX = (time: ValidTime | undefined | null): number => { + if (!time) return gutter; + return scale.project(validTimeToDate(time).getTime()) + gutter; + }; + + const toggleSegment = (segmentKey: string) => { + const segment = timeline.segments.find( + (s) => s.timespan.key === segmentKey, + ); + if (segment) { + timeline.toggleTimeSegment(segment); + } + }; + const filteredGroups = $derived( getFailedOrPendingGroups(groups, $eventStatusFilter), ); @@ -374,13 +417,7 @@ class="pointer-events-none absolute inset-0 opacity-30" style={gridBackgroundStyle} > - +
+ {#if !loading} + + + + {/if} +