Skip to content

Commit 344f8d1

Browse files
committed
✨ Add Timeline trace view tab for message conversations
1 parent ba031f2 commit 344f8d1

9 files changed

Lines changed: 1283 additions & 0 deletions

File tree

src/Frontend/src/components/messages/MessageView.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import NoData from "../NoData.vue";
55
import TimeSince from "../TimeSince.vue";
66
import FlowDiagram from "./FlowDiagram/FlowDiagram.vue";
77
import SequenceDiagram from "./SequenceDiagram.vue";
8+
import TimelineDiagram from "./TimelineDiagram/TimelineDiagram.vue";
89
import routeLinks from "@/router/routeLinks";
910
import BodyView from "@/components/messages/BodyView.vue";
1011
import HeadersView from "@/components/messages/HeadersView.vue";
@@ -66,6 +67,10 @@ const tabs = computed(() => {
6667
text: "Sequence Diagram",
6768
component: SequenceDiagram,
6869
});
70+
currentTabs.push({
71+
text: "Timeline",
72+
component: TimelineDiagram,
73+
});
6974
// Add the "Saga Diagram" tab only if the saga has been participated in
7075
if (hasParticipatedInSaga?.value) {
7176
currentTabs.push({
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<script setup lang="ts">
2+
import { computed } from "vue";
3+
import { useTimelineDiagramStore } from "@/stores/TimelineDiagramStore";
4+
import { storeToRefs } from "pinia";
5+
import { generateTimeTicks, generateWallClockTicks, timeToX, AXIS_HEIGHT, ROW_HEIGHT } from "@/resources/TimelineDiagram/TimelineModel";
6+
7+
const props = withDefaults(
8+
defineProps<{
9+
chartWidth: number;
10+
position?: "top" | "bottom";
11+
bottomY?: number;
12+
}>(),
13+
{ position: "top" }
14+
);
15+
16+
const store = useTimelineDiagramStore();
17+
const { visibleMinTime, visibleMaxTime, rows, useUtc, labelWidth } = storeToRefs(store);
18+
19+
const isBottom = computed(() => props.position === "bottom");
20+
21+
const ticks = computed(() =>
22+
isBottom.value
23+
? generateWallClockTicks(visibleMinTime.value, visibleMaxTime.value, useUtc.value)
24+
: generateTimeTicks(visibleMinTime.value, visibleMaxTime.value)
25+
);
26+
27+
const axisY = computed(() => (isBottom.value ? props.bottomY! : AXIS_HEIGHT));
28+
const totalHeight = computed(() => AXIS_HEIGHT + rows.value.length * ROW_HEIGHT);
29+
30+
function tickX(timeMs: number) {
31+
return labelWidth.value + timeToX(timeMs, visibleMinTime.value, visibleMaxTime.value, props.chartWidth);
32+
}
33+
</script>
34+
35+
<template>
36+
<g class="timeline-axis">
37+
<!-- axis line -->
38+
<line :x1="labelWidth" :y1="axisY" :x2="labelWidth + chartWidth" :y2="axisY" class="axis-line" />
39+
<!-- ticks and labels -->
40+
<g v-for="tick in ticks" :key="tick.timeMs">
41+
<line :x1="tickX(tick.timeMs)" :y1="isBottom ? axisY : axisY - 4" :x2="tickX(tick.timeMs)" :y2="isBottom ? axisY + 4 : axisY" class="tick-mark" />
42+
<!-- Bottom axis: two-line label (date + time) -->
43+
<text v-if="isBottom" :x="tickX(tick.timeMs)" :y="axisY + 14" class="tick-label">
44+
<tspan :x="tickX(tick.timeMs)" dy="0">{{ tick.label }}</tspan>
45+
<tspan :x="tickX(tick.timeMs)" dy="12">{{ tick.label2 }}</tspan>
46+
</text>
47+
<!-- Top axis: single-line label -->
48+
<text v-else :x="tickX(tick.timeMs)" :y="axisY - 8" class="tick-label">{{ tick.label }}</text>
49+
<!-- gridlines only for top axis -->
50+
<line v-if="!isBottom" :x1="tickX(tick.timeMs)" :y1="AXIS_HEIGHT" :x2="tickX(tick.timeMs)" :y2="totalHeight" class="gridline" />
51+
</g>
52+
</g>
53+
</template>
54+
55+
<style scoped>
56+
.axis-line {
57+
stroke: var(--gray80);
58+
stroke-width: 1;
59+
}
60+
.tick-mark {
61+
stroke: var(--gray60);
62+
stroke-width: 1;
63+
}
64+
.tick-label {
65+
font-size: 10px;
66+
fill: var(--gray40);
67+
text-anchor: middle;
68+
}
69+
.gridline {
70+
stroke: var(--gray90);
71+
stroke-width: 1;
72+
stroke-dasharray: 2 4;
73+
}
74+
</style>
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<script setup lang="ts">
2+
import { computed } from "vue";
3+
import { useTimelineDiagramStore } from "@/stores/TimelineDiagramStore";
4+
import { storeToRefs } from "pinia";
5+
import { timeToX, rowToY, BAR_HEIGHT, MIN_BAR_WIDTH, DELIVERY_LINE_HEIGHT, type TimelineBar } from "@/resources/TimelineDiagram/TimelineModel";
6+
7+
const props = defineProps<{
8+
chartWidth: number;
9+
}>();
10+
11+
const store = useTimelineDiagramStore();
12+
const { bars, rowIndexByBarId, visibleMinTime, visibleMaxTime, highlightId, showDeliveryTime, labelWidth } = storeToRefs(store);
13+
14+
const barPositions = computed(() =>
15+
bars.value.map((bar) => {
16+
const rowIndex = rowIndexByBarId.value.get(bar.id) ?? 0;
17+
const y = rowToY(rowIndex);
18+
19+
// Delivery line: sentMs → processingStartMs
20+
const deliveryX = labelWidth.value + timeToX(bar.sentMs, visibleMinTime.value, visibleMaxTime.value, props.chartWidth);
21+
const procStartX = labelWidth.value + timeToX(bar.processingStartMs, visibleMinTime.value, visibleMaxTime.value, props.chartWidth);
22+
const deliveryWidth = Math.max(procStartX - deliveryX, 0);
23+
24+
// Processing bar: processingStartMs → processedAtMs
25+
const procEndX = labelWidth.value + timeToX(bar.processedAtMs, visibleMinTime.value, visibleMaxTime.value, props.chartWidth);
26+
const procWidth = Math.max(procEndX - procStartX, MIN_BAR_WIDTH);
27+
28+
return { bar, y, deliveryX, deliveryWidth, procX: procStartX, procWidth };
29+
})
30+
);
31+
32+
function barFill(bar: TimelineBar) {
33+
if (bar.isFailed) return "var(--error)";
34+
if (bar.isSelected) return "var(--highlight)";
35+
if (highlightId.value === bar.id) return "var(--highlight-background)";
36+
return "var(--gray80)";
37+
}
38+
39+
function barTextFill(bar: TimelineBar) {
40+
if (bar.isFailed || bar.isSelected) return "white";
41+
return "var(--gray20)";
42+
}
43+
44+
function deliveryLineFill(bar: TimelineBar) {
45+
if (bar.isFailed) return "var(--error)";
46+
if (bar.isSelected) return "var(--highlight)";
47+
if (highlightId.value === bar.id) return "var(--highlight-background)";
48+
return "var(--gray95)";
49+
}
50+
51+
function onBarClick(bar: TimelineBar, event: MouseEvent) {
52+
store.navigateTo(bar, event.shiftKey);
53+
}
54+
55+
function onBarEnter(bar: TimelineBar, event: MouseEvent) {
56+
store.setHighlightId(bar.id);
57+
updateTooltipPos(bar, event);
58+
}
59+
60+
function onBarMove(bar: TimelineBar, event: MouseEvent) {
61+
updateTooltipPos(bar, event);
62+
}
63+
64+
function onBarLeave() {
65+
store.setHighlightId(undefined);
66+
store.hideTooltip();
67+
}
68+
69+
function updateTooltipPos(bar: TimelineBar, event: MouseEvent) {
70+
const wrapper = (event.currentTarget as SVGElement).closest(".wrapper");
71+
if (!wrapper) return;
72+
const rect = wrapper.getBoundingClientRect();
73+
store.showTooltip(bar, event.clientX - rect.left + 12, event.clientY - rect.top + 12);
74+
}
75+
</script>
76+
77+
<template>
78+
<g class="timeline-bars">
79+
<g v-for="pos in barPositions" :key="pos.bar.id" class="bar-group" @click="onBarClick(pos.bar, $event)" @mouseenter="onBarEnter(pos.bar, $event)" @mousemove="onBarMove(pos.bar, $event)" @mouseleave="onBarLeave">
80+
<!-- Delivery time line (thin) -->
81+
<rect
82+
v-if="showDeliveryTime && pos.deliveryWidth > 0"
83+
:x="pos.deliveryX"
84+
:y="pos.y + BAR_HEIGHT / 2 - DELIVERY_LINE_HEIGHT / 2"
85+
:width="pos.deliveryWidth"
86+
:height="DELIVERY_LINE_HEIGHT"
87+
:fill="deliveryLineFill(pos.bar)"
88+
class="delivery-line"
89+
/>
90+
<!-- Processing time bar -->
91+
<rect :x="pos.procX" :y="pos.y" :width="pos.procWidth" :height="BAR_HEIGHT" :fill="barFill(pos.bar)" rx="3" ry="3" class="bar-rect" />
92+
<!-- Clipped text label inside the processing bar -->
93+
<clipPath :id="`clip-${pos.bar.id}`">
94+
<rect :x="pos.procX" :y="pos.y" :width="pos.procWidth - 4" :height="BAR_HEIGHT" />
95+
</clipPath>
96+
<text v-if="pos.procWidth > 30" :x="pos.procX + 4" :y="pos.y + BAR_HEIGHT / 2 + 4" :fill="barTextFill(pos.bar)" :clip-path="`url(#clip-${pos.bar.id})`" class="bar-label">
97+
{{ pos.bar.typeName }}
98+
</text>
99+
</g>
100+
</g>
101+
</template>
102+
103+
<style scoped>
104+
.bar-group {
105+
cursor: pointer;
106+
}
107+
.bar-rect {
108+
transition: opacity 0.15s;
109+
}
110+
.delivery-line {
111+
transition: opacity 0.15s;
112+
}
113+
.bar-group:hover .bar-rect,
114+
.bar-group:hover .delivery-line {
115+
opacity: 0.85;
116+
}
117+
.bar-label {
118+
font-size: 10px;
119+
pointer-events: none;
120+
}
121+
</style>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<script setup lang="ts">
2+
import { computed } from "vue";
3+
import { useTimelineDiagramStore } from "@/stores/TimelineDiagramStore";
4+
import { storeToRefs } from "pinia";
5+
import { timeToX, rowToY, BAR_HEIGHT } from "@/resources/TimelineDiagram/TimelineModel";
6+
7+
const props = defineProps<{
8+
chartWidth: number;
9+
}>();
10+
11+
const store = useTimelineDiagramStore();
12+
const { bars, rowIndexByBarId, visibleMinTime, visibleMaxTime, labelWidth } = storeToRefs(store);
13+
14+
interface Connection {
15+
key: string;
16+
x1: number;
17+
y1: number;
18+
x2: number;
19+
y2: number;
20+
}
21+
22+
const connections = computed<Connection[]>(() => {
23+
const barsByMessageId = new Map(bars.value.map((b) => [b.messageId, b]));
24+
const result: Connection[] = [];
25+
26+
for (const child of bars.value) {
27+
if (!child.relatedToMessageId) continue;
28+
const parent = barsByMessageId.get(child.relatedToMessageId);
29+
if (!parent) continue;
30+
31+
const parentRowIdx = rowIndexByBarId.value.get(parent.id) ?? 0;
32+
const childRowIdx = rowIndexByBarId.value.get(child.id) ?? 0;
33+
34+
// Line from parent's processedAt to child's processing start (box start)
35+
const x1 = labelWidth.value + timeToX(parent.processedAtMs, visibleMinTime.value, visibleMaxTime.value, props.chartWidth);
36+
const y1 = rowToY(parentRowIdx) + BAR_HEIGHT / 2;
37+
const x2 = labelWidth.value + timeToX(child.processingStartMs, visibleMinTime.value, visibleMaxTime.value, props.chartWidth);
38+
const y2 = rowToY(childRowIdx) + BAR_HEIGHT / 2;
39+
40+
result.push({ key: `${parent.id}-${child.id}`, x1, y1, x2, y2 });
41+
}
42+
43+
return result;
44+
});
45+
</script>
46+
47+
<template>
48+
<g class="timeline-connections">
49+
<line v-for="conn in connections" :key="conn.key" :x1="conn.x1" :y1="conn.y1" :x2="conn.x2" :y2="conn.y2" class="connection-line" />
50+
</g>
51+
</template>
52+
53+
<style scoped>
54+
.connection-line {
55+
stroke: var(--gray60);
56+
stroke-width: 1;
57+
stroke-dasharray: 3 3;
58+
pointer-events: none;
59+
}
60+
</style>

0 commit comments

Comments
 (0)