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}
+