Conversation
754f91b to
344f8d1
Compare
Move canvas context from module-level singleton to function-local variable in measureLabelWidth to satisfy PR review checklist.
|
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). |
| <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> |
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
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
MessageViewas 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.
| 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 } | ||
| ); | ||
|
|
There was a problem hiding this comment.
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.
| 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); | |
| } | |
| ); |
| const path = isFailed ? routeLinks.messages.failedMessage.link(bar.id) : routeLinks.messages.successMessage.link(bar.messageId, bar.id); | ||
|
|
||
| if (newTab) { | ||
| window.open(`#${path}`, "_blank"); |
There was a problem hiding this comment.
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.
| window.open(`#${path}`, "_blank"); | |
| const newWindow = window.open(`#${path}`, "_blank", "noopener,noreferrer"); | |
| if (newWindow) { | |
| newWindow.opener = null; | |
| } |
| break; | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| // 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); | |
| } |
| break; | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| // 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); | |
| } |
| 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); | ||
| } |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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.
| 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; |
| 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))); |
There was a problem hiding this comment.
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.
| // 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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).
| .minimap { | ||
| position: relative; | ||
| height: v-bind(MINIMAP_HEIGHT + 'px'); | ||
| background: #f5f5f5; | ||
| border: 1px solid #ddd; |
There was a problem hiding this comment.
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).
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.
Reviewer Checklist