Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Frontend/src/components/messages/MessageView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from "vue";
import { useTimelineDiagramStore } from "@/stores/TimelineDiagramStore";
import { storeToRefs } from "pinia";
import { generateTimeTicks, generateWallClockTicks, timeToX, AXIS_HEIGHT, ROW_HEIGHT } from "@/resources/TimelineDiagram/TimelineModel";

const props = withDefaults(
defineProps<{
chartWidth: number;
position?: "top" | "bottom";
bottomY?: number;
}>(),
{ position: "top" }
);

const store = useTimelineDiagramStore();
const { visibleMinTime, visibleMaxTime, rows, useUtc, labelWidth } = storeToRefs(store);

const isBottom = computed(() => props.position === "bottom");

const ticks = computed(() =>

Check failure on line 21 in src/Frontend/src/components/messages/TimelineDiagram/TimelineAxis.vue

View workflow job for this annotation

GitHub Actions / Windows

Replace `␍⏎··isBottom.value␍⏎····?·generateWallClockTicks(visibleMinTime.value,·visibleMaxTime.value,·useUtc.value)␍⏎····:·generateTimeTicks(visibleMinTime.value,·visibleMaxTime.value)␍⏎` with `·(isBottom.value·?·generateWallClockTicks(visibleMinTime.value,·visibleMaxTime.value,·useUtc.value)·:·generateTimeTicks(visibleMinTime.value,·visibleMaxTime.value))`

Check failure on line 21 in src/Frontend/src/components/messages/TimelineDiagram/TimelineAxis.vue

View workflow job for this annotation

GitHub Actions / windows-standalone

Replace `␍⏎··isBottom.value␍⏎····?·generateWallClockTicks(visibleMinTime.value,·visibleMaxTime.value,·useUtc.value)␍⏎····:·generateTimeTicks(visibleMinTime.value,·visibleMaxTime.value)␍⏎` with `·(isBottom.value·?·generateWallClockTicks(visibleMinTime.value,·visibleMaxTime.value,·useUtc.value)·:·generateTimeTicks(visibleMinTime.value,·visibleMaxTime.value))`
isBottom.value
? generateWallClockTicks(visibleMinTime.value, visibleMaxTime.value, useUtc.value)
: generateTimeTicks(visibleMinTime.value, visibleMaxTime.value)
);

const axisY = computed(() => (isBottom.value ? props.bottomY! : AXIS_HEIGHT));
const totalHeight = computed(() => AXIS_HEIGHT + rows.value.length * ROW_HEIGHT);

function tickX(timeMs: number) {
return labelWidth.value + timeToX(timeMs, visibleMinTime.value, visibleMaxTime.value, props.chartWidth);
}
</script>

<template>
<g class="timeline-axis">
<!-- axis line -->
<line :x1="labelWidth" :y1="axisY" :x2="labelWidth + chartWidth" :y2="axisY" class="axis-line" />
<!-- ticks and labels -->
<g v-for="tick in ticks" :key="tick.timeMs">
<line :x1="tickX(tick.timeMs)" :y1="isBottom ? axisY : axisY - 4" :x2="tickX(tick.timeMs)" :y2="isBottom ? axisY + 4 : axisY" class="tick-mark" />
<!-- Bottom axis: two-line label (date + time) -->
<text v-if="isBottom" :x="tickX(tick.timeMs)" :y="axisY + 14" class="tick-label">
<tspan :x="tickX(tick.timeMs)" dy="0">{{ tick.label }}</tspan>
<tspan :x="tickX(tick.timeMs)" dy="12">{{ tick.label2 }}</tspan>
</text>
<!-- Top axis: single-line label -->
<text v-else :x="tickX(tick.timeMs)" :y="axisY - 8" class="tick-label">{{ tick.label }}</text>
<!-- gridlines only for top axis -->
<line v-if="!isBottom" :x1="tickX(tick.timeMs)" :y1="AXIS_HEIGHT" :x2="tickX(tick.timeMs)" :y2="totalHeight" class="gridline" />
</g>
</g>
</template>

<style scoped>
.axis-line {
stroke: var(--gray80);
stroke-width: 1;
}
.tick-mark {
stroke: var(--gray60);
stroke-width: 1;
}
.tick-label {
font-size: 10px;
fill: var(--gray40);
text-anchor: middle;
}
.gridline {
stroke: var(--gray90);
stroke-width: 1;
stroke-dasharray: 2 4;
}
</style>
121 changes: 121 additions & 0 deletions src/Frontend/src/components/messages/TimelineDiagram/TimelineBars.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed } from "vue";
import { useTimelineDiagramStore } from "@/stores/TimelineDiagramStore";
import { storeToRefs } from "pinia";
import { timeToX, rowToY, BAR_HEIGHT, MIN_BAR_WIDTH, DELIVERY_LINE_HEIGHT, type TimelineBar } from "@/resources/TimelineDiagram/TimelineModel";

const props = defineProps<{
chartWidth: number;
}>();

const store = useTimelineDiagramStore();
const { bars, rowIndexByBarId, visibleMinTime, visibleMaxTime, highlightId, showDeliveryTime, labelWidth } = storeToRefs(store);

const barPositions = computed(() =>
bars.value.map((bar) => {
const rowIndex = rowIndexByBarId.value.get(bar.id) ?? 0;
const y = rowToY(rowIndex);

// Delivery line: sentMs → processingStartMs
const deliveryX = labelWidth.value + timeToX(bar.sentMs, visibleMinTime.value, visibleMaxTime.value, props.chartWidth);
const procStartX = labelWidth.value + timeToX(bar.processingStartMs, visibleMinTime.value, visibleMaxTime.value, props.chartWidth);
const deliveryWidth = Math.max(procStartX - deliveryX, 0);

// Processing bar: processingStartMs → processedAtMs
const procEndX = labelWidth.value + timeToX(bar.processedAtMs, visibleMinTime.value, visibleMaxTime.value, props.chartWidth);
const procWidth = Math.max(procEndX - procStartX, MIN_BAR_WIDTH);

return { bar, y, deliveryX, deliveryWidth, procX: procStartX, procWidth };
})
);

function barFill(bar: TimelineBar) {
if (bar.isFailed) return "var(--error)";
if (bar.isSelected) return "var(--highlight)";
if (highlightId.value === bar.id) return "var(--highlight-background)";
return "var(--gray80)";
}

function barTextFill(bar: TimelineBar) {
if (bar.isFailed || bar.isSelected) return "white";
return "var(--gray20)";
}

function deliveryLineFill(bar: TimelineBar) {
if (bar.isFailed) return "var(--error)";
if (bar.isSelected) return "var(--highlight)";
if (highlightId.value === bar.id) return "var(--highlight-background)";
return "var(--gray95)";
}

function onBarClick(bar: TimelineBar, event: MouseEvent) {
store.navigateTo(bar, event.shiftKey);
}

function onBarEnter(bar: TimelineBar, event: MouseEvent) {
store.setHighlightId(bar.id);
updateTooltipPos(bar, event);
}

function onBarMove(bar: TimelineBar, event: MouseEvent) {
updateTooltipPos(bar, event);
}

function onBarLeave() {
store.setHighlightId(undefined);
store.hideTooltip();
}

function updateTooltipPos(bar: TimelineBar, event: MouseEvent) {
const wrapper = (event.currentTarget as SVGElement).closest(".wrapper");
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
store.showTooltip(bar, event.clientX - rect.left + 12, event.clientY - rect.top + 12);
}
</script>

<template>
<g class="timeline-bars">
<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">
<!-- Delivery time line (thin) -->
<rect
v-if="showDeliveryTime && pos.deliveryWidth > 0"
:x="pos.deliveryX"
:y="pos.y + BAR_HEIGHT / 2 - DELIVERY_LINE_HEIGHT / 2"
:width="pos.deliveryWidth"
:height="DELIVERY_LINE_HEIGHT"
:fill="deliveryLineFill(pos.bar)"
class="delivery-line"
/>
<!-- Processing time bar -->
<rect :x="pos.procX" :y="pos.y" :width="pos.procWidth" :height="BAR_HEIGHT" :fill="barFill(pos.bar)" rx="3" ry="3" class="bar-rect" />
<!-- Clipped text label inside the processing bar -->
<clipPath :id="`clip-${pos.bar.id}`">
<rect :x="pos.procX" :y="pos.y" :width="pos.procWidth - 4" :height="BAR_HEIGHT" />
</clipPath>
<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">
{{ pos.bar.typeName }}
</text>
</g>
</g>
</template>

<style scoped>
.bar-group {
cursor: pointer;
}
.bar-rect {
transition: opacity 0.15s;
}
.delivery-line {
transition: opacity 0.15s;
}
.bar-group:hover .bar-rect,
.bar-group:hover .delivery-line {
opacity: 0.85;
}
.bar-label {
font-size: 10px;
pointer-events: none;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from "vue";
import { useTimelineDiagramStore } from "@/stores/TimelineDiagramStore";
import { storeToRefs } from "pinia";
import { timeToX, rowToY, BAR_HEIGHT } from "@/resources/TimelineDiagram/TimelineModel";

const props = defineProps<{
chartWidth: number;
}>();

const store = useTimelineDiagramStore();
const { bars, rowIndexByBarId, visibleMinTime, visibleMaxTime, labelWidth } = storeToRefs(store);

interface Connection {
key: string;
x1: number;
y1: number;
x2: number;
y2: number;
}

const connections = computed<Connection[]>(() => {
const barsByMessageId = new Map(bars.value.map((b) => [b.messageId, b]));
const result: Connection[] = [];

for (const child of bars.value) {
if (!child.relatedToMessageId) continue;
const parent = barsByMessageId.get(child.relatedToMessageId);
if (!parent) continue;

const parentRowIdx = rowIndexByBarId.value.get(parent.id) ?? 0;
const childRowIdx = rowIndexByBarId.value.get(child.id) ?? 0;

// Line from parent's processedAt to child's processing start (box start)
const x1 = labelWidth.value + timeToX(parent.processedAtMs, visibleMinTime.value, visibleMaxTime.value, props.chartWidth);
const y1 = rowToY(parentRowIdx) + BAR_HEIGHT / 2;
const x2 = labelWidth.value + timeToX(child.processingStartMs, visibleMinTime.value, visibleMaxTime.value, props.chartWidth);
const y2 = rowToY(childRowIdx) + BAR_HEIGHT / 2;

result.push({ key: `${parent.id}-${child.id}`, x1, y1, x2, y2 });
}

return result;
});
</script>

<template>
<g class="timeline-connections">
<line v-for="conn in connections" :key="conn.key" :x1="conn.x1" :y1="conn.y1" :x2="conn.x2" :y2="conn.y2" class="connection-line" />
</g>
</template>

<style scoped>
.connection-line {
stroke: var(--gray60);
stroke-width: 1;
stroke-dasharray: 3 3;
pointer-events: none;
}
</style>
Loading
Loading