diff --git a/src/Frontend/src/components/messages/MessageView.vue b/src/Frontend/src/components/messages/MessageView.vue index b25a2a2a81..a078f36d7b 100644 --- a/src/Frontend/src/components/messages/MessageView.vue +++ b/src/Frontend/src/components/messages/MessageView.vue @@ -5,6 +5,7 @@ import NoData from "../NoData.vue"; import TimeSince from "../TimeSince.vue"; import FlowDiagram from "./FlowDiagram/FlowDiagram.vue"; import SequenceDiagram from "./SequenceDiagram.vue"; +import TimelineDiagram from "./TimelineDiagram/TimelineDiagram.vue"; import routeLinks from "@/router/routeLinks"; import BodyView from "@/components/messages/BodyView.vue"; import HeadersView from "@/components/messages/HeadersView.vue"; @@ -66,6 +67,10 @@ const tabs = computed(() => { text: "Sequence Diagram", component: SequenceDiagram, }); + currentTabs.push({ + text: "Timeline", + component: TimelineDiagram, + }); // Add the "Saga Diagram" tab only if the saga has been participated in if (hasParticipatedInSaga?.value) { currentTabs.push({ diff --git a/src/Frontend/src/components/messages/TimelineDiagram/TimelineAxis.vue b/src/Frontend/src/components/messages/TimelineDiagram/TimelineAxis.vue new file mode 100644 index 0000000000..a476903b4a --- /dev/null +++ b/src/Frontend/src/components/messages/TimelineDiagram/TimelineAxis.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/src/Frontend/src/components/messages/TimelineDiagram/TimelineBars.vue b/src/Frontend/src/components/messages/TimelineDiagram/TimelineBars.vue new file mode 100644 index 0000000000..2d490faced --- /dev/null +++ b/src/Frontend/src/components/messages/TimelineDiagram/TimelineBars.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/src/Frontend/src/components/messages/TimelineDiagram/TimelineConnections.vue b/src/Frontend/src/components/messages/TimelineDiagram/TimelineConnections.vue new file mode 100644 index 0000000000..66313b47d3 --- /dev/null +++ b/src/Frontend/src/components/messages/TimelineDiagram/TimelineConnections.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/Frontend/src/components/messages/TimelineDiagram/TimelineDiagram.vue b/src/Frontend/src/components/messages/TimelineDiagram/TimelineDiagram.vue new file mode 100644 index 0000000000..a20ca0f304 --- /dev/null +++ b/src/Frontend/src/components/messages/TimelineDiagram/TimelineDiagram.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/src/Frontend/src/components/messages/TimelineDiagram/TimelineEndpoints.vue b/src/Frontend/src/components/messages/TimelineDiagram/TimelineEndpoints.vue new file mode 100644 index 0000000000..48d7aacc18 --- /dev/null +++ b/src/Frontend/src/components/messages/TimelineDiagram/TimelineEndpoints.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/Frontend/src/components/messages/TimelineDiagram/TimelineMinimap.vue b/src/Frontend/src/components/messages/TimelineDiagram/TimelineMinimap.vue new file mode 100644 index 0000000000..887532cedf --- /dev/null +++ b/src/Frontend/src/components/messages/TimelineDiagram/TimelineMinimap.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/Frontend/src/resources/TimelineDiagram/TimelineModel.ts b/src/Frontend/src/resources/TimelineDiagram/TimelineModel.ts new file mode 100644 index 0000000000..5cc94b46fc --- /dev/null +++ b/src/Frontend/src/resources/TimelineDiagram/TimelineModel.ts @@ -0,0 +1,271 @@ +import Message, { MessageStatus, MessageIntent } from "@/resources/Message"; +import { NServiceBusHeaders } from "@/resources/Header"; +import { formatTypeName, dotNetTimespanToMilliseconds } from "@/composables/formatUtils"; +import dayjs from "@/utils/dayjs"; + +export interface TimelineBar { + id: string; + messageId: string; + typeName: string; + fullTypeName: string; + endpointName: string; + /** When the message was sent (time_sent) */ + sentMs: number; + /** When processing started (processed_at - processing_time) */ + processingStartMs: number; + /** When processing completed (processed_at) */ + processedAtMs: number; + /** Delivery time: network transit + queue wait (sentMs → processingStartMs) */ + deliveryMs: number; + /** Handler execution time */ + processingMs: number; + /** Critical time: full lifecycle (sentMs → processedAtMs) */ + criticalMs: number; + isFailed: boolean; + isSelected: boolean; + intent: MessageIntent; + /** message_id of the message whose handler sent this one */ + relatedToMessageId?: string; +} + +export interface TimelineRow { + barId: string; + typeName: string; + endpointName: string; + rowIndex: number; + depth: number; + /** Whether this row is the last child of its parent */ + isLastChild: boolean; + /** For each ancestor depth 0..depth-2: true if a vertical pass-through line should be drawn (ancestor is not last child) */ + continuations: boolean[]; +} + +export interface TimelineModel { + bars: TimelineBar[]; + rows: TimelineRow[]; + minTime: number; + maxTime: number; +} + +export interface TimeTick { + timeMs: number; + label: string; + /** Second line for wall clock ticks (time portion) */ + label2?: string; +} + +export const ROW_HEIGHT = 40; +export const ROW_PADDING = 8; +export const BAR_HEIGHT = 24; +export const MIN_LABEL_WIDTH = 120; +export const AXIS_HEIGHT = 30; +export const BOTTOM_AXIS_HEIGHT = 40; +export const MIN_BAR_WIDTH = 4; +export const CHART_PADDING = 20; +export const DELIVERY_LINE_HEIGHT = 2; + +export function createTimelineModel(messages: Message[], selectedId?: string): TimelineModel { + const bars: TimelineBar[] = []; + + for (const message of messages) { + const endpointName = message.receiving_endpoint?.name; + if (!endpointName) continue; + + const sentMs = new Date(message.time_sent).getTime(); + const processedAtMs = new Date(message.processed_at).getTime(); + const processingMs = dotNetTimespanToMilliseconds(message.processing_time); + const processingStartMs = processedAtMs - processingMs; + const deliveryMs = processingStartMs - sentMs; + const criticalMs = processedAtMs - sentMs; + + const isFailed = message.status === MessageStatus.Failed || message.status === MessageStatus.RepeatedFailure || message.status === MessageStatus.ArchivedFailure; + const relatedToMessageId = message.headers?.find((h) => h.key === NServiceBusHeaders.RelatedTo)?.value; + + bars.push({ + id: message.id, + messageId: message.message_id, + typeName: formatTypeName(message.message_type), + fullTypeName: message.message_type, + endpointName, + sentMs, + processingStartMs, + processedAtMs, + deliveryMs, + processingMs, + criticalMs, + isFailed, + isSelected: message.id === selectedId, + intent: message.message_intent, + relatedToMessageId, + }); + } + + // Build tree: parent messageId → children (sorted by sentMs) + const barsByMessageId = new Map(bars.map((b) => [b.messageId, b])); + const childrenOf = new Map(); + const roots: TimelineBar[] = []; + + for (const bar of bars) { + if (bar.relatedToMessageId && barsByMessageId.has(bar.relatedToMessageId)) { + const siblings = childrenOf.get(bar.relatedToMessageId) ?? []; + siblings.push(bar); + childrenOf.set(bar.relatedToMessageId, siblings); + } else { + roots.push(bar); + } + } + + // Sort roots and each child group by sentMs + roots.sort((a, b) => a.sentMs - b.sentMs); + for (const children of childrenOf.values()) { + children.sort((a, b) => a.sentMs - b.sentMs); + } + + // 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); + } + } + for (let i = 0; i < roots.length; i++) { + traverse(roots[i], 0, [], i === roots.length - 1); + } + + const allTimes = bars.flatMap((b) => [b.sentMs, b.processedAtMs]); + const minTime = allTimes.length ? Math.min(...allTimes) : 0; + const maxTime = allTimes.length ? Math.max(...allTimes) : 0; + + return { bars, rows, minTime, maxTime }; +} + +export function timeToX(timeMs: number, minTime: number, maxTime: number, chartWidth: number): number { + const range = maxTime - minTime; + if (range <= 0) return CHART_PADDING; + return CHART_PADDING + ((timeMs - minTime) / range) * (chartWidth - 2 * CHART_PADDING); +} + +export function rowToY(rowIndex: number): number { + return AXIS_HEIGHT + rowIndex * ROW_HEIGHT + ROW_PADDING; +} + +export function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): TimeTick[] { + const range = maxTime - minTime; + if (range <= 0) return []; + + const intervals = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 30000, 60000, 120000, 300000, 600000, 1800000, 3600000]; + + let interval = intervals[intervals.length - 1]; + for (const candidate of intervals) { + if (range / candidate <= maxTicks) { + interval = candidate; + break; + } + } + + const ticks: TimeTick[] = []; + const firstTick = Math.ceil(minTime / interval) * interval; + + for (let t = firstTick; t <= maxTime; t += interval) { + ticks.push({ + timeMs: t, + label: formatTickLabel(t, minTime, range), + }); + } + + return ticks; +} + +export function generateWallClockTicks(minTime: number, maxTime: number, useUtc: boolean, maxTicks = 8): TimeTick[] { + const range = maxTime - minTime; + if (range <= 0) return []; + + const intervals = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 30000, 60000, 120000, 300000, 600000, 1800000, 3600000]; + + let interval = intervals[intervals.length - 1]; + for (const candidate of intervals) { + if (range / candidate <= maxTicks) { + interval = candidate; + break; + } + } + + const ticks: TimeTick[] = []; + const firstTick = Math.ceil(minTime / interval) * interval; + + for (let t = firstTick; t <= maxTime; t += interval) { + const d = useUtc ? dayjs.utc(t) : dayjs(t); + ticks.push({ timeMs: t, label: d.format("YYYY-MM-DD"), label2: d.format("HH:mm:ss.SSS") }); + } + + return ticks; +} + +function formatTickLabel(timeMs: number, minTime: number, range: number): string { + const elapsed = timeMs - minTime; + if (range < 1000) { + return `${elapsed.toFixed(0)}ms`; + } + if (range < 60000) { + const s = elapsed / 1000; + return `${s.toFixed(3)}s`; + } + if (range < 3600000) { + const totalSec = Math.floor(elapsed / 1000); + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return `${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; + } + const totalSec = Math.floor(elapsed / 1000); + const h = Math.floor(totalSec / 3600); + const min = Math.floor((totalSec % 3600) / 60); + const sec = totalSec % 60; + return `${h}:${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; +} + +const INDENT_PX = 16; + +let measureCtx: CanvasRenderingContext2D | null = null; + +export function measureLabelWidth(rows: TimelineRow[]): number { + if (!rows.length) return MIN_LABEL_WIDTH; + if (!measureCtx) { + measureCtx = document.createElement("canvas").getContext("2d"); + } + if (!measureCtx) return MIN_LABEL_WIDTH; + + let maxWidth = 0; + for (const row of rows) { + const indent = row.depth * INDENT_PX; + measureCtx.font = "bold 11px sans-serif"; + const typeW = measureCtx.measureText(row.typeName).width; + measureCtx.font = "11px sans-serif"; + const endpointW = measureCtx.measureText(row.endpointName).width; + // Use the wider of typeName vs endpointName (they stack vertically) + maxWidth = Math.max(maxWidth, indent + Math.max(typeW, endpointW)); + } + // padding(8+8) + separator margin(4) + return Math.max(Math.ceil(maxWidth + 20), MIN_LABEL_WIDTH); +} + +export function formatTimeForDisplay(timeMs: number, useUtc: boolean): string { + const d = dayjs(timeMs); + return useUtc ? d.utc().format("HH:mm:ss.SSS") : d.format("HH:mm:ss.SSS"); +} + +export function formatDateTimeForDisplay(timeMs: number, useUtc: boolean): string { + if (!timeMs) return ""; + const d = dayjs(timeMs); + return useUtc ? d.utc().format("YYYY-MM-DD HH:mm:ss.SSS") : d.format("YYYY-MM-DD HH:mm:ss.SSS"); +} + +export function formatDurationForDisplay(ms: number): string { + if (ms < 1) return "<1 ms"; + if (ms < 1000) return `${ms.toFixed(1)} ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(2)} s`; + if (ms < 3600000) return `${(ms / 60000).toFixed(1)} min`; + return `${(ms / 3600000).toFixed(1)} hr`; +} diff --git a/src/Frontend/src/stores/TimelineDiagramStore.ts b/src/Frontend/src/stores/TimelineDiagramStore.ts new file mode 100644 index 0000000000..729966a2ad --- /dev/null +++ b/src/Frontend/src/stores/TimelineDiagramStore.ts @@ -0,0 +1,188 @@ +import { acceptHMRUpdate, defineStore, storeToRefs } from "pinia"; +import { computed, ref, watch } from "vue"; +import { createTimelineModel, measureLabelWidth, type TimelineBar, type TimelineRow } from "@/resources/TimelineDiagram/TimelineModel"; +import { useMessageStore } from "./MessageStore"; +import { useRouter } from "vue-router"; +import routeLinks from "@/router/routeLinks"; +import { MessageStatus } from "@/resources/Message"; + +const STORAGE_KEY_UTC = "servicepulse-timeline-useUtc"; +const STORAGE_KEY_DELIVERY_TIME = "servicepulse-timeline-showDeliveryTime"; +const STORAGE_KEY_CONNECTIONS = "servicepulse-timeline-showConnections"; + +export const useTimelineDiagramStore = defineStore("TimelineDiagramStore", () => { + const messageStore = useMessageStore(); + const { state, conversationData } = storeToRefs(messageStore); + const router = useRouter(); + + const bars = ref([]); + const rows = ref([]); + const minTime = ref(0); + const maxTime = ref(0); + const highlightId = ref(); + 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(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))); + + 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 setHighlightId(id?: string) { + highlightId.value = id; + } + + function showTooltip(bar: TimelineBar, x: number, y: number) { + tooltipBar.value = bar; + tooltipX.value = x; + tooltipY.value = y; + } + + function hideTooltip() { + tooltipBar.value = null; + } + + function toggleUtc() { + useUtc.value = !useUtc.value; + } + + function toggleDeliveryTime() { + showDeliveryTime.value = !showDeliveryTime.value; + } + + function toggleConnections() { + showConnections.value = !showConnections.value; + } + + function resetZoom() { + zoomStart.value = 0; + zoomEnd.value = 1; + } + + function setZoomWindow(start: number, end: number) { + zoomStart.value = Math.max(0, start); + zoomEnd.value = Math.min(1, end); + } + + function zoom(factor: number, anchorFraction: number) { + const currentSpan = zoomEnd.value - zoomStart.value; + const newSpan = Math.min(Math.max(currentSpan * factor, 0.001), 1); + + // Anchor the zoom around the cursor position + const anchor = zoomStart.value + anchorFraction * currentSpan; + let newStart = anchor - anchorFraction * newSpan; + let newEnd = anchor + (1 - anchorFraction) * newSpan; + + // Clamp to [0, 1] + if (newStart < 0) { + newEnd = Math.min(newEnd - newStart, 1); + newStart = 0; + } + if (newEnd > 1) { + newStart = Math.max(newStart - (newEnd - 1), 0); + newEnd = 1; + } + + zoomStart.value = newStart; + zoomEnd.value = newEnd; + } + + function refreshConversation() { + if (messageStore.state.data.conversation_id) messageStore.loadConversation(messageStore.state.data.conversation_id); + } + + function navigateTo(bar: TimelineBar, newTab = false) { + const status = conversationData.value.data.find((m) => m.id === bar.id)?.status; + const isFailed = status === MessageStatus.Failed || status === MessageStatus.RepeatedFailure || status === MessageStatus.ArchivedFailure; + const path = isFailed ? routeLinks.messages.failedMessage.link(bar.id) : routeLinks.messages.successMessage.link(bar.messageId, bar.id); + + if (newTab) { + window.open(`#${path}`, "_blank"); + } else { + router.push({ path }); + } + } + + return { + bars, + rows, + rowIndexByBarId, + minTime, + maxTime, + visibleMinTime, + visibleMaxTime, + highlightId, + failedToLoad, + selectedId, + labelWidth, + conversationId, + isLoading, + useUtc, + showDeliveryTime, + showConnections, + zoomStart, + zoomEnd, + tooltipBar, + tooltipX, + tooltipY, + setHighlightId, + showTooltip, + hideTooltip, + toggleUtc, + toggleDeliveryTime, + toggleConnections, + resetZoom, + setZoomWindow, + zoom, + refreshConversation, + navigateTo, + }; +}); + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useTimelineDiagramStore, import.meta.hot)); +} + +export type TimelineDiagramStore = ReturnType;