Skip to content

Timeline diagram#2887

Open
ramonsmits wants to merge 2 commits intomasterfrom
feature/timeline-diagram
Open

Timeline diagram#2887
ramonsmits wants to merge 2 commits intomasterfrom
feature/timeline-diagram

Conversation

@ramonsmits
Copy link
Copy Markdown
Member

@ramonsmits ramonsmits commented Mar 14, 2026

Adds a timeline view as an alternative to the sequence diagram. This is based on the traces view of jaeger, application insights, zipkin, etc.

However, this nicely only shows the messages processing durations which is a nice highlevel view. It can show the queue time and also see how message are changed if needed.

It also has a tooltip showing a bit of message information. Messages are clickable and will navigate to that message.

Screencast From 2026-03-14 00-58-37

Reviewer Checklist

  • Components are broken down into sensible and maintainable sub-components.
  • Styles are scoped to the component using it. If multiple components need to share CSS, then a .css file is created containing the shared CSS and imported into component scoped style sections.
  • Naming is consistent with existing code, and adequately describes the component or function being introduced
  • Only functions utilizing Vue state or lifecycle hooks are named as composables (i.e. starting with 'use');
  • No module-level state is being introduced. If so, request the PR author to move the state to the corresponding Pinia store.

@ramonsmits ramonsmits force-pushed the feature/timeline-diagram branch from 754f91b to 344f8d1 Compare March 14, 2026 00:10
@dvdstelt dvdstelt added this to the 2.7.0 milestone Mar 16, 2026
@ramonsmits ramonsmits added the Type: Feature Type: Feature label Mar 16, 2026
@ramonsmits ramonsmits changed the title Feature/timeline diagram Timeline diagram Mar 16, 2026
Move canvas context from module-level singleton to function-local
variable in measureLabelWidth to satisfy PR review checklist.
@ramonsmits
Copy link
Copy Markdown
Member Author

Regarding the checklist for:

No module-level state is being introduced.

Made a change in f712fd9: the measureCtx singleton (let measureCtx: CanvasRenderingContext2D | null = null) was module-level mutable state. Moved to a function-local const inside measureLabelWidth().

All reactive state lives in TimelineDiagramStore (Pinia).

@ramonsmits ramonsmits removed this from the 2.7.0 milestone Mar 17, 2026
Comment on lines +94 to +101
<div class="toolbar-controls">
<button class="toolbar-btn" :class="{ active: useUtc }" :title="useUtc ? 'Showing UTC time' : 'Showing local time'" @click="store.toggleUtc()">
{{ useUtc ? "UTC" : "Local" }}
</button>
<button class="toolbar-btn" :class="{ active: showDeliveryTime }" title="Show delivery time (queue wait + network transit)" @click="store.toggleDeliveryTime()">Delivery time</button>
<button class="toolbar-btn" :class="{ active: showConnections }" title="Show connections between related messages" @click="store.toggleConnections()">Connections</button>
<button v-if="isZoomed" class="toolbar-btn" @click="store.resetZoom()">Reset zoom</button>
</div>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ramonsmits I think there are already patterns in the codebase about toolbar buttons.
It may be good to review these and make sure we are not creating another pattern here.
My instinct is to stick to the bootstrapper button; there's no need to come up with new CSS for these buttons.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new “Timeline” visualization for message conversations (trace-style view) as an alternative to the existing sequence diagram in the Message view.

Changes:

  • Introduces a new Pinia store to build timeline state (bars/rows), manage zoom, and handle navigation/tooltip state.
  • Adds a timeline model layer for building tree-structured rows and generating axis ticks/formatting helpers.
  • Adds a set of Vue components to render the timeline (axis, endpoints/labels, bars, connections, minimap) and wires it into MessageView as a new tab.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/Frontend/src/stores/TimelineDiagramStore.ts New Pinia store managing timeline data, zoom, local preferences, tooltip, and navigation.
src/Frontend/src/resources/TimelineDiagram/TimelineModel.ts New model utilities/types for transforming conversation messages into timeline rows/bars and tick generation.
src/Frontend/src/components/messages/TimelineDiagram/TimelineDiagram.vue Main timeline view container: layout, toolbar, minimap, tooltip, wheel zoom integration.
src/Frontend/src/components/messages/TimelineDiagram/TimelineMinimap.vue Minimap overview + drag-to-pan zoom window.
src/Frontend/src/components/messages/TimelineDiagram/TimelineEndpoints.vue Renders row labels (type/endpoint) including tree guides and row click navigation.
src/Frontend/src/components/messages/TimelineDiagram/TimelineBars.vue Renders delivery/processing bars and hover interactions/tooltip updates.
src/Frontend/src/components/messages/TimelineDiagram/TimelineConnections.vue Renders dashed “related message” connector lines.
src/Frontend/src/components/messages/TimelineDiagram/TimelineAxis.vue Renders top elapsed-time ticks and bottom wall-clock ticks/gridlines.
src/Frontend/src/components/messages/MessageView.vue Adds the new “Timeline” tab to the message view.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +59 to +72
watch(
() => conversationData.value.data,
(data) => {
if (data.length) {
const model = createTimelineModel(data, state.value.data.id);
bars.value = model.bars;
rows.value = model.rows;
minTime.value = model.minTime;
maxTime.value = model.maxTime;
}
},
{ immediate: true }
);

Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createTimelineModel() bakes isSelected into each bar using state.value.data.id, but this watcher only reruns when conversationData.value.data changes. When navigating between messages within the same conversation (same conversationData array), the selected bar will not update. Consider also watching the selected message id and updating bars/rows selection state (or recreating the model) when it changes.

Suggested change
watch(
() => conversationData.value.data,
(data) => {
if (data.length) {
const model = createTimelineModel(data, state.value.data.id);
bars.value = model.bars;
rows.value = model.rows;
minTime.value = model.minTime;
maxTime.value = model.maxTime;
}
},
{ immediate: true }
);
function rebuildTimelineModel(selectedMessageId?: string) {
const data = conversationData.value.data;
if (data.length) {
const model = createTimelineModel(data, selectedMessageId ?? state.value.data.id);
bars.value = model.bars;
rows.value = model.rows;
minTime.value = model.minTime;
maxTime.value = model.maxTime;
}
}
watch(
() => conversationData.value.data,
() => {
rebuildTimelineModel();
},
{ immediate: true }
);
watch(
() => selectedId.value,
(id) => {
rebuildTimelineModel(id);
}
);

Copilot uses AI. Check for mistakes.
const path = isFailed ? routeLinks.messages.failedMessage.link(bar.id) : routeLinks.messages.successMessage.link(bar.messageId, bar.id);

if (newTab) {
window.open(`#${path}`, "_blank");
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

window.open(..., "_blank") is used without noopener/noreferrer, which can allow reverse-tabnabbing via window.opener. Consider opening the new tab with noopener (and/or setting newWindow.opener = null) to prevent the opened page from navigating the origin tab.

Suggested change
window.open(`#${path}`, "_blank");
const newWindow = window.open(`#${path}`, "_blank", "noopener,noreferrer");
if (newWindow) {
newWindow.opener = null;
}

Copilot uses AI. Check for mistakes.
break;
}
}

Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateTimeTicks() caps the interval list at 1 hour. For conversations longer than ~8 hours (default maxTicks = 8), range / interval will exceed maxTicks, so this function can return far more ticks than intended (potentially impacting readability/perf). Consider extending the interval list (e.g., 2h, 6h, 12h, 1d, …) or deriving an interval dynamically so the returned tick count stays within maxTicks.

Suggested change
// Fallback: if the predefined intervals still yield more than maxTicks,
// derive a larger interval dynamically to cap the number of ticks.
if (range / interval > maxTicks) {
interval = Math.ceil(range / maxTicks);
}

Copilot uses AI. Check for mistakes.
break;
}
}

Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateWallClockTicks() uses the same interval list capped at 1 hour, so for long ranges this can return many more than maxTicks labels (and each tick label is two lines). Consider using the same fix as generateTimeTicks() (larger intervals or dynamic interval selection) to keep tick count bounded.

Suggested change
// Fallback: if even the largest predefined interval would produce more than maxTicks,
// increase the interval dynamically to keep the tick count bounded.
if (range / interval > maxTicks) {
interval = Math.ceil(range / maxTicks);
}

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +87
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}

function onMouseMove(event: MouseEvent) {
if (!dragging || !minimapRef.value) return;
const rect = minimapRef.value.getBoundingClientRect();
const fraction = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const span = zoomEnd.value - zoomStart.value;
let newStart = fraction - dragOffset;
let newEnd = newStart + span;
if (newStart < 0) {
newEnd -= newStart;
newStart = 0;
}
if (newEnd > 1) {
newStart -= newEnd - 1;
newEnd = 1;
}
store.setZoomWindow(Math.max(0, newStart), Math.min(1, newEnd));
}

function onMouseUp() {
dragging = false;
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component adds document-level mousemove/mouseup listeners on mousedown, but only removes them on mouseup. If the component unmounts mid-drag (route change / tab switch), those listeners can leak and keep calling into a destroyed component instance. Consider adding onUnmounted() cleanup to remove the listeners and reset dragging.

Copilot uses AI. Check for mistakes.
Comment on lines +62 to +67
if (data.length) {
const model = createTimelineModel(data, state.value.data.id);
bars.value = model.bars;
rows.value = model.rows;
minTime.value = model.minTime;
maxTime.value = model.maxTime;
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This watcher only updates state when data.length is truthy. If the conversation data becomes empty (e.g., store reset, navigation, or failed fetch returning empty), the previous bars/rows/minTime/maxTime remain, leaving stale UI. Consider adding an else branch to clear these refs (and likely reset zoom/tooltip/highlight) when data is empty.

Suggested change
if (data.length) {
const model = createTimelineModel(data, state.value.data.id);
bars.value = model.bars;
rows.value = model.rows;
minTime.value = model.minTime;
maxTime.value = model.maxTime;
if (data && data.length) {
const model = createTimelineModel(data, state.value.data.id);
bars.value = model.bars;
rows.value = model.rows;
minTime.value = model.minTime;
maxTime.value = model.maxTime;
} else {
bars.value = [];
rows.value = [];
minTime.value = 0;
maxTime.value = 0;
zoomStart.value = 0;
zoomEnd.value = 1;
tooltipBar.value = null;
highlightId.value = undefined;

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +57
const useUtc = ref(localStorage.getItem(STORAGE_KEY_UTC) === "true");
const showDeliveryTime = ref(localStorage.getItem(STORAGE_KEY_DELIVERY_TIME) !== "false");
const showConnections = ref(localStorage.getItem(STORAGE_KEY_CONNECTIONS) === "true");

// Zoom state: visible window expressed as fractions [0..1] of the full time range
const zoomStart = ref(0);
const zoomEnd = ref(1);

const isLoading = computed(() => conversationData.value.loading);
const failedToLoad = computed(() => conversationData.value.failed_to_load === true);
const selectedId = computed(() => state.value.data.id);
const labelWidth = computed(() => measureLabelWidth(rows.value));
const conversationId = computed(() => state.value.data.conversation_id ?? "");

// The visible time window after zoom
const visibleMinTime = computed(() => {
const range = maxTime.value - minTime.value;
return minTime.value + zoomStart.value * range;
});
const visibleMaxTime = computed(() => {
const range = maxTime.value - minTime.value;
return minTime.value + zoomEnd.value * range;
});

// Tooltip state — managed here so the parent component can render it as HTML outside SVG
const tooltipBar = ref<TimelineBar | null>(null);
const tooltipX = ref(0);
const tooltipY = ref(0);

// barId → rowIndex lookup for O(1) access from child components
const rowIndexByBarId = computed(() => new Map(rows.value.map((r) => [r.barId, r.rowIndex])));

watch(useUtc, (v) => localStorage.setItem(STORAGE_KEY_UTC, String(v)));
watch(showDeliveryTime, (v) => localStorage.setItem(STORAGE_KEY_DELIVERY_TIME, String(v)));
watch(showConnections, (v) => localStorage.setItem(STORAGE_KEY_CONNECTIONS, String(v)));
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LocalStorage reads/writes here are unguarded. In some environments (restricted storage, privacy mode, quota errors), localStorage.getItem/setItem can throw and break the store setup. Other stores in this repo wrap localStorage access in try/catch and fall back to defaults—consider doing the same here for both the initial getItem calls and the watch(... setItem ...) side effects.

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +133
// DFS to build rows with tree guide data
const rows: TimelineRow[] = [];
function traverse(bar: TimelineBar, depth: number, continuations: boolean[], isLast: boolean) {
rows.push({ barId: bar.id, typeName: bar.typeName, endpointName: bar.endpointName, rowIndex: rows.length, depth, isLastChild: isLast, continuations: [...continuations] });
const children = childrenOf.get(bar.messageId) ?? [];
const nextContinuations = [...continuations, !isLast];
for (let i = 0; i < children.length; i++) {
traverse(children[i], depth + 1, nextContinuations, i === children.length - 1);
}
}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The continuations array is documented as “ancestor depth 0..depth-2”, but traverse() builds nextContinuations = [...continuations, !isLast] and passes it directly to children. This makes row.continuations.length equal to depth for depth>0 (and includes the parent column), which will render extra vertical lines and can prevent the “last child” connector from visually terminating. Adjust the continuation propagation so each row only receives ancestor continuations excluding its parent (or update the template to ignore the last continuation entry).

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +104
.minimap {
position: relative;
height: v-bind(MINIMAP_HEIGHT + 'px');
background: #f5f5f5;
border: 1px solid #ddd;
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

height: v-bind(MINIMAP_HEIGHT + 'px') is likely not valid SFC CSS v-bind() usage (it typically expects an identifier or a quoted expression string). This may fail to compile or produce an unexpected CSS var lookup. Consider either hardcoding height: 50px (since it’s a constant) or exposing a minimapHeightPx string from <script setup> and referencing it via v-bind(minimapHeightPx).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Type: Feature Type: Feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants