From 5d1ce6b6d42d8bec44682d4522b9d8bcf2949dc7 Mon Sep 17 00:00:00 2001 From: Tegan Churchill Date: Thu, 25 Jun 2026 13:32:38 -0700 Subject: [PATCH 1/2] feat(timeline): collapse idle event timespans (DT-3929) Squash-rebase of the collapse-idle-time feature onto SharedHistoryBuffer's timeline performance rewrite (window virtualization, grouped-event-buffer, sticky-canvas scroll compositing, imperative panel-shift transforms). The collapse feature is layered onto SHB's architecture in the timeline-graph/ directory layout: - X-axis time->pixel projection flows through the collapse-aware TimelineScale/Viewport (projectX); SHB's Y-axis row virtualization and imperative active-panel transform are preserved. The two axes are orthogonal so they compose cleanly. - validTimeToDate routes its parse through SHB's cachedParseJSON, keeping the session parse cache inside the feature's abstraction. Reconciliations where the two approaches were mutually exclusive: - Dropped the per-row position cache: it keyed on (width,y,len) and would serve stale points when collapse toggles; its parseJSON cost is now covered by cachedParseJSON. - Kept SHB's CSS grid background and label-only axis (the axis ticks are no longer collapse-aware; collapsed regions are still marked by the overlay). - group-details-row uses SHB's ResizeObserver/onHeight panel-height path instead of the $activeGroupHeight store. Includes one fix to a pre-existing SHB type error in svg/timeline-graph-row-rendering.test.ts (literal-narrowed comparison). --- .../lines-and-dots/end-time-interval.svelte | 2 +- .../components/lines-and-dots/svg/dot.svelte | 2 +- .../svg/timeline-graph-row-rendering.test.ts | 2 +- .../graph-widget.svelte | 18 +- .../group-details-row.svelte | 22 ++- .../timeline-axis.svelte | 19 +- .../timeline-collapsed-layer.svelte | 183 ++++++++++++++++++ .../timeline-graph-row.svelte | 108 ++++------- .../timeline-graph.svelte | 74 +++++-- .../timeline-graph/timeline-scale.svelte.ts | 160 +++++++++++++++ .../timeline-graph/timeline.svelte.test.ts | 119 ++++++++++++ .../timeline-graph/timeline.svelte.ts | 182 +++++++++++++++++ .../lines-and-dots/timeline-graph/timespan.ts | 80 ++++++++ .../lines-and-dots/timeline-graph/types.ts | 8 + .../utils/build-time-segments.ts | 109 +++++++++++ .../timeline-graph/viewport.svelte.ts | 33 ++++ .../workflow-row.svelte | 7 +- src/lib/holocene/icon/paths.ts | 6 + src/lib/holocene/icon/svg/minus.svelte | 8 + src/lib/holocene/icon/svg/plus.svelte | 8 + .../icon/svg/timeline-collapse.svelte | 23 +++ src/lib/holocene/tooltip.svelte | 8 +- src/lib/i18n/locales/en/workflows.ts | 4 + .../layouts/workflow-timeline-layout.svelte | 37 +++- src/lib/stores/event-view.ts | 5 + src/lib/utilities/format-time.test.ts | 162 ++++++++++++++++ src/lib/utilities/format-time.ts | 107 +++++++--- src/lib/utilities/type-predicates.ts | 5 + .../timeline-collapse-idle-time.spec.ts | 98 ++++++++++ 29 files changed, 1450 insertions(+), 149 deletions(-) rename src/lib/components/lines-and-dots/{svg => timeline-graph}/graph-widget.svelte (84%) rename src/lib/components/lines-and-dots/{svg => timeline-graph}/group-details-row.svelte (88%) rename src/lib/components/lines-and-dots/{svg => timeline-graph}/timeline-axis.svelte (71%) create mode 100644 src/lib/components/lines-and-dots/timeline-graph/timeline-collapsed-layer.svelte rename src/lib/components/lines-and-dots/{svg => timeline-graph}/timeline-graph-row.svelte (75%) rename src/lib/components/lines-and-dots/{svg => timeline-graph}/timeline-graph.svelte (91%) create mode 100644 src/lib/components/lines-and-dots/timeline-graph/timeline-scale.svelte.ts create mode 100644 src/lib/components/lines-and-dots/timeline-graph/timeline.svelte.test.ts create mode 100644 src/lib/components/lines-and-dots/timeline-graph/timeline.svelte.ts create mode 100644 src/lib/components/lines-and-dots/timeline-graph/timespan.ts create mode 100644 src/lib/components/lines-and-dots/timeline-graph/types.ts create mode 100644 src/lib/components/lines-and-dots/timeline-graph/utils/build-time-segments.ts create mode 100644 src/lib/components/lines-and-dots/timeline-graph/viewport.svelte.ts rename src/lib/components/lines-and-dots/{svg => timeline-graph}/workflow-row.svelte (92%) create mode 100644 src/lib/holocene/icon/svg/minus.svelte create mode 100644 src/lib/holocene/icon/svg/plus.svelte create mode 100644 src/lib/holocene/icon/svg/timeline-collapse.svelte create mode 100644 src/lib/utilities/type-predicates.ts create mode 100644 tests/integration/timeline-collapse-idle-time.spec.ts 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..7a47891cee 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} > - +
+ + + +