diff --git a/src/Frontend/src/components/messages/MessageView.vue b/src/Frontend/src/components/messages/MessageView.vue
index b54f6679d..fef7c0a3c 100644
--- a/src/Frontend/src/components/messages/MessageView.vue
+++ b/src/Frontend/src/components/messages/MessageView.vue
@@ -10,6 +10,7 @@ import TimeSince from "../TimeSince.vue";
import moment from "moment";
import ConfirmDialog from "../ConfirmDialog.vue";
import FlowDiagram from "./FlowDiagram.vue";
+import SequenceDiagram from "./SequenceDiagram.vue";
import EditRetryDialog from "../failedmessages/EditRetryDialog.vue";
import routeLinks from "@/router/routeLinks";
import { EditAndRetryConfig } from "@/resources/Configuration";
@@ -43,6 +44,7 @@ const showEditRetryModal = ref(false);
const configuration = useConfiguration();
const isMassTransitConnected = useIsMassTransitConnected();
+const showAllMessages = window.defaultConfig.showAllMessages;
async function loadFailedMessage() {
const response = await useFetchFromServiceControl(`errors/last/${id.value}`);
@@ -71,7 +73,7 @@ async function loadFailedMessage() {
}
updateMessageDeleteDate(message);
- await downloadHeadersAndBody(message);
+ await fetchMessageDetails(message);
failedMessage.value = message;
}
@@ -115,7 +117,7 @@ async function retryMessage() {
}
}
-async function downloadHeadersAndBody(message: ExtendedFailedMessage) {
+async function fetchMessageDetails(message: ExtendedFailedMessage) {
if (isError(message)) return;
try {
@@ -349,11 +351,13 @@ onUnmounted(() => {
+
+
diff --git a/src/Frontend/src/components/messages/SequenceDiagram.vue b/src/Frontend/src/components/messages/SequenceDiagram.vue
new file mode 100644
index 000000000..bcfa1b396
--- /dev/null
+++ b/src/Frontend/src/components/messages/SequenceDiagram.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Frontend/src/components/messages/SequenceDiagram/EndpointsComponent.vue b/src/Frontend/src/components/messages/SequenceDiagram/EndpointsComponent.vue
new file mode 100644
index 000000000..10cb1da76
--- /dev/null
+++ b/src/Frontend/src/components/messages/SequenceDiagram/EndpointsComponent.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+ {{ endpoint.name }}
+
+
+
+
+
diff --git a/src/Frontend/src/components/messages/SequenceDiagram/HandlersComponent.vue b/src/Frontend/src/components/messages/SequenceDiagram/HandlersComponent.vue
new file mode 100644
index 000000000..19bc2b00f
--- /dev/null
+++ b/src/Frontend/src/components/messages/SequenceDiagram/HandlersComponent.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+ store.setHighlightId(handler.incomingId)" @mouseleave="() => store.setHighlightId()" />
+
+
+ store.setHighlightId(handler.incomingId)"
+ @mouseleave="() => store.setHighlightId()"
+ >
+
+ {{ handler.messageType }}
+
+
+
+
+
diff --git a/src/Frontend/src/components/messages/SequenceDiagram/RoutesComponent.vue b/src/Frontend/src/components/messages/SequenceDiagram/RoutesComponent.vue
new file mode 100644
index 000000000..6e3497acd
--- /dev/null
+++ b/src/Frontend/src/components/messages/SequenceDiagram/RoutesComponent.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ store.setHighlightId(arrow.id)"
+ @mouseleave="() => store.setHighlightId()"
+ >
+
+
+
+ {{ arrow.messageType }}
+
+
+
+
+
+
diff --git a/src/Frontend/src/components/messages/SequenceDiagram/TimelineComponent.vue b/src/Frontend/src/components/messages/SequenceDiagram/TimelineComponent.vue
new file mode 100644
index 000000000..faad6f882
--- /dev/null
+++ b/src/Frontend/src/components/messages/SequenceDiagram/TimelineComponent.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/src/Frontend/src/resources/FailedMessage.ts b/src/Frontend/src/resources/FailedMessage.ts
index 74f1b208d..20002e990 100644
--- a/src/Frontend/src/resources/FailedMessage.ts
+++ b/src/Frontend/src/resources/FailedMessage.ts
@@ -1,5 +1,6 @@
import type EndpointDetails from "@/resources/EndpointDetails";
import type Header from "@/resources/Header";
+import { ConversationModel } from "./SequenceDiagram/SequenceModel";
export default interface FailedMessage {
id: string;
@@ -37,6 +38,7 @@ export interface ExtendedFailedMessage extends FailedMessage {
bodyUnavailable: boolean;
headers: Header[];
conversationId: string;
+ conversation?: ConversationModel;
messageBody: string;
contentType: string;
isEditAndRetryEnabled: boolean;
diff --git a/src/Frontend/src/resources/Message.ts b/src/Frontend/src/resources/Message.ts
index 977497bbb..c2d38b17e 100644
--- a/src/Frontend/src/resources/Message.ts
+++ b/src/Frontend/src/resources/Message.ts
@@ -16,10 +16,12 @@ export default interface Message {
conversation_id: string;
headers: Header[];
status: MessageStatus;
- message_intent: string;
+ message_intent: MessageIntent;
body_url: string;
body_size: number;
instance_id: string;
+ invoked_sagas?: SagaInfo[];
+ originates_from_saga?: SagaInfo;
}
export interface ExtendedMessage extends Message {
notFound: boolean;
@@ -36,3 +38,18 @@ export enum MessageStatus {
ArchivedFailure = "archivedFailure",
RetryIssued = "retryIssued",
}
+
+export enum MessageIntent {
+ Send = "send",
+ Publish = "publish",
+ Subscribe = "subscribe",
+ Unsubscribe = "unsubscribe",
+ Reply = "reply",
+ Init = "init",
+}
+
+interface SagaInfo {
+ change_status?: string;
+ saga_type: string;
+ saga_id: string;
+}
diff --git a/src/Frontend/src/resources/SequenceDiagram/Endpoint.ts b/src/Frontend/src/resources/SequenceDiagram/Endpoint.ts
new file mode 100644
index 000000000..dfa1babd5
--- /dev/null
+++ b/src/Frontend/src/resources/SequenceDiagram/Endpoint.ts
@@ -0,0 +1,121 @@
+import { NServiceBusHeaders } from "../Header";
+import Message from "../Message";
+import { Handler } from "./Handler";
+
+export interface Endpoint {
+ readonly name: string;
+ readonly hosts: EndpointHost[];
+ readonly hostId: string;
+ readonly handlers: Handler[];
+ addHandler(handler: Handler): void;
+}
+
+export interface EndpointHost {
+ readonly host: string;
+ readonly hostId: string;
+ readonly versions: string[];
+}
+
+export function createProcessingEndpoint(message: Message): Endpoint {
+ return new EndpointItem(
+ message.receiving_endpoint.name,
+ message.receiving_endpoint.host,
+ message.receiving_endpoint.host_id,
+ message.receiving_endpoint.name === message.sending_endpoint.name && message.receiving_endpoint.host === message.sending_endpoint.host ? message.headers.find((h) => h.key === NServiceBusHeaders.NServiceBusVersion)?.value : undefined
+ );
+}
+
+export function createSendingEndpoint(message: Message): Endpoint {
+ return new EndpointItem(message.sending_endpoint.name, message.sending_endpoint.host, message.sending_endpoint.host_id, message.headers.find((h) => h.key === NServiceBusHeaders.NServiceBusVersion)?.value);
+}
+
+export class EndpointRegistry {
+ #store = new Map();
+
+ register(item: Endpoint) {
+ let endpoint = this.#store.get(item.name);
+ if (!endpoint) {
+ endpoint = item as EndpointItem;
+ this.#store.set(endpoint.name, endpoint);
+ }
+
+ item.hosts.forEach((host) => endpoint.addHost(host as Host));
+ }
+
+ get(item: Endpoint) {
+ return this.#store.get(item.name)! as Endpoint;
+ }
+}
+
+class EndpointItem implements Endpoint {
+ private _hosts: Map;
+ private _name: string;
+ private _handlers: Handler[] = [];
+
+ constructor(name: string, host: string, id: string, version?: string) {
+ const initialHost = new Host(host, id, version);
+ this._hosts = new Map([[initialHost.equatableKey, initialHost]]);
+ this._name = name;
+ }
+
+ get name() {
+ return this._name;
+ }
+ get hosts() {
+ return [...this._hosts].map(([, host]) => host);
+ }
+ get host() {
+ return [...this._hosts].map(([, host]) => host.host).join(",");
+ }
+ get hostId() {
+ return [...this._hosts].map(([, host]) => host.hostId).join(",");
+ }
+ get handlers() {
+ return [...this._handlers];
+ }
+
+ addHost(host: Host) {
+ if (!this._hosts.has(host.equatableKey)) {
+ this._hosts.set(host.equatableKey, host);
+ } else {
+ const existing = this._hosts.get(host.equatableKey)!;
+ existing.addVersions(host.versions);
+ }
+ }
+
+ addHandler(handler: Handler) {
+ this._handlers.push(handler);
+ }
+}
+
+class Host implements EndpointHost {
+ private _host: string;
+ private _hostId: string;
+ private _versions: Set;
+
+ constructor(host: string, hostId: string, version?: string) {
+ this._host = host;
+ this._hostId = hostId;
+ this._versions = new Set();
+ this.addVersions([version]);
+ }
+
+ get host() {
+ return this._host;
+ }
+ get hostId() {
+ return this._hostId;
+ }
+
+ get versions() {
+ return [...this._versions];
+ }
+
+ get equatableKey() {
+ return `${this._hostId}###${this._host}`;
+ }
+
+ addVersions(versions: (string | undefined)[]) {
+ versions.filter((version) => version).forEach((version) => this._versions.add(version!.toLowerCase()));
+ }
+}
diff --git a/src/Frontend/src/resources/SequenceDiagram/Handler.ts b/src/Frontend/src/resources/SequenceDiagram/Handler.ts
new file mode 100644
index 000000000..5e13c367b
--- /dev/null
+++ b/src/Frontend/src/resources/SequenceDiagram/Handler.ts
@@ -0,0 +1,133 @@
+import { NServiceBusHeaders } from "../Header";
+import Message, { MessageStatus } from "../Message";
+import { Direction, MessageProcessingRoute, RoutedMessage } from "./RoutedMessage";
+import { Endpoint } from "./Endpoint";
+import { friendlyTypeName } from "./SequenceModel";
+
+export interface Handler {
+ readonly id: string;
+ name?: string;
+ readonly endpoint: Endpoint;
+ readonly isPartOfSaga: boolean;
+ partOfSaga?: string;
+ state: HandlerState;
+ inMessage?: RoutedMessage;
+ readonly outMessages: RoutedMessage[];
+ processedAt?: Date;
+ readonly handledAt?: Date;
+ processingTime?: number;
+ readonly direction: Direction;
+ route?: MessageProcessingRoute;
+ readonly selectedMessage?: Message;
+ updateProcessedAt(timeSent: Date): void;
+ addOutMessage(routedMessage: RoutedMessage): void;
+}
+
+export enum HandlerState {
+ Fail,
+ Success,
+}
+
+export const ConversationStartHandlerName = "First";
+
+export function createSendingHandler(message: Message, sendingEndpoint: Endpoint): Handler {
+ return new HandlerItem(message.headers.find((h) => h.key === NServiceBusHeaders.RelatedTo)?.value ?? ConversationStartHandlerName, sendingEndpoint);
+}
+
+export function createProcessingHandler(message: Message, processingEndpoint: Endpoint): Handler {
+ const handler = new HandlerItem(message.message_id, processingEndpoint);
+ updateProcessingHandler(handler, message);
+ return handler;
+}
+
+export class HandlerRegistry {
+ #store = new Map();
+ private storeKey = (id: string, endpointName: string) => `${id}###${endpointName}`;
+
+ register(handler: Handler) {
+ const existing = this.#store.get(this.storeKey(handler.id, handler.endpoint.name));
+ if (existing) return { handler: existing, isNew: false };
+
+ this.#store.set(this.storeKey(handler.id, handler.endpoint.name), handler as HandlerItem);
+ return { handler, isNew: true };
+ }
+}
+
+export function updateProcessingHandler(handler: Handler, message: Message) {
+ handler.processedAt = new Date(message.processed_at);
+ //assuming if we have days in the timespan then something is very, very wrong
+ //TODO: extract logic since it's also currently used in AuditList
+ const [hh, mm, ss] = message.processing_time.split(":");
+ handler.processingTime = ((parseInt(hh) * 60 + parseInt(mm)) * 60 + parseFloat(ss)) * 1000;
+ handler.name = friendlyTypeName(message.message_type);
+
+ if ((message.invoked_sagas?.length ?? 0) > 0) {
+ handler.partOfSaga = message.invoked_sagas!.map((saga) => friendlyTypeName(saga.saga_type)).join(", ");
+ }
+
+ switch (message.status) {
+ case MessageStatus.ArchivedFailure:
+ case MessageStatus.Failed:
+ case MessageStatus.RepeatedFailure:
+ handler.state = HandlerState.Fail;
+ break;
+ default:
+ handler.state = HandlerState.Success;
+ }
+}
+
+class HandlerItem implements Handler {
+ private _id: string;
+ private _endpoint: Endpoint;
+ private _processedAtGuess?: Date;
+ private _outMessages: RoutedMessage[];
+ name?: string;
+ partOfSaga?: string;
+ inMessage?: RoutedMessage;
+ state: HandlerState = HandlerState.Fail;
+ processedAt?: Date;
+ processingTime?: number;
+ route?: MessageProcessingRoute;
+
+ constructor(id: string, endpoint: Endpoint) {
+ this._id = id;
+ this._endpoint = endpoint;
+ this._outMessages = [];
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get endpoint() {
+ return this._endpoint;
+ }
+
+ get isPartOfSaga() {
+ return this.partOfSaga != null;
+ }
+
+ get handledAt() {
+ return this.processedAt ?? this._processedAtGuess;
+ }
+
+ get selectedMessage() {
+ return this.route?.fromRoutedMessage?.selectedMessage;
+ }
+
+ get outMessages() {
+ return [...this._outMessages];
+ }
+
+ get direction() {
+ return this.outMessages[0]?.direction ?? Direction.Right;
+ }
+
+ updateProcessedAt(timeSent: Date) {
+ if (!this._processedAtGuess || this._processedAtGuess.getTime() > timeSent.getTime()) this._processedAtGuess = timeSent;
+ }
+
+ addOutMessage(routedMessage: RoutedMessage) {
+ this._outMessages = [routedMessage, ...this._outMessages].sort((a, b) => (a.sentTime?.getTime() ?? 0) - (b.sentTime?.getTime() ?? 0));
+ }
+}
diff --git a/src/Frontend/src/resources/SequenceDiagram/RoutedMessage.ts b/src/Frontend/src/resources/SequenceDiagram/RoutedMessage.ts
new file mode 100644
index 000000000..04c5a62f1
--- /dev/null
+++ b/src/Frontend/src/resources/SequenceDiagram/RoutedMessage.ts
@@ -0,0 +1,110 @@
+import EndpointDetails from "../EndpointDetails";
+import { NServiceBusHeaders } from "../Header";
+import Message, { MessageIntent, MessageStatus } from "../Message";
+import { Handler } from "./Handler";
+import { friendlyTypeName } from "./SequenceModel";
+
+export interface RoutedMessage {
+ name: string;
+ readonly selectedMessage: Message;
+ fromHandler?: Handler;
+ toHandler?: Handler;
+ route?: MessageProcessingRoute;
+ direction: Direction;
+ type: RoutedMessageType;
+ readonly receiving: EndpointDetails;
+ readonly sending: EndpointDetails;
+ readonly sentTime: Date | undefined;
+ readonly messageId: string;
+ readonly status: MessageStatus;
+}
+
+export interface MessageProcessingRoute {
+ readonly name?: string;
+ readonly fromRoutedMessage?: RoutedMessage;
+ readonly processingHandler?: Handler;
+}
+
+export enum Direction {
+ Left,
+ Right,
+}
+
+export enum RoutedMessageType {
+ Event,
+ Command,
+ Local,
+ Timeout,
+}
+
+export function createRoute(routedMessage: RoutedMessage, processingHandler: Handler): MessageProcessingRoute {
+ return new MessageProcessingRouteItem(routedMessage, processingHandler);
+}
+
+export function createRoutedMessage(message: Message): RoutedMessage {
+ const routedMessage = new RoutedMessageItem(message);
+
+ if (message.message_intent === MessageIntent.Publish) routedMessage.type = RoutedMessageType.Event;
+ else {
+ const isTimeoutString = message.headers.find((h) => h.key === NServiceBusHeaders.IsSagaTimeoutMessage)?.value;
+ const isTimeout = (isTimeoutString ?? "") === "true";
+ if (isTimeout) routedMessage.type = RoutedMessageType.Timeout;
+ else if (message.receiving_endpoint.host_id === message.sending_endpoint.host_id && message.receiving_endpoint.name === message.sending_endpoint.name) routedMessage.type = RoutedMessageType.Local;
+ else routedMessage.type = RoutedMessageType.Command;
+ }
+
+ return routedMessage;
+}
+
+class MessageProcessingRouteItem implements MessageProcessingRoute {
+ readonly name?: string;
+ private _fromRoutedMessage?: RoutedMessageItem;
+ readonly processingHandler?: Handler;
+
+ constructor(routedMessage?: RoutedMessageItem, processingHandler?: Handler) {
+ this._fromRoutedMessage = routedMessage;
+ this.processingHandler = processingHandler;
+
+ if (routedMessage && this.processingHandler) {
+ this.name = `${processingHandler?.name}(${routedMessage.messageId})`;
+ }
+
+ if (routedMessage) routedMessage.route = this;
+ if (processingHandler) processingHandler.route = this;
+ }
+
+ get fromRoutedMessage() {
+ return this._fromRoutedMessage as RoutedMessage | undefined;
+ }
+}
+
+class RoutedMessageItem implements RoutedMessage {
+ readonly selectedMessage: Message;
+ readonly name: string;
+ fromHandler?: Handler;
+ toHandler?: Handler;
+ route?: MessageProcessingRoute;
+ direction = Direction.Left;
+ type = RoutedMessageType.Command;
+
+ constructor(message: Message) {
+ this.selectedMessage = message;
+ this.name = friendlyTypeName(message.message_type) ?? "";
+ }
+
+ get receiving() {
+ return this.selectedMessage.receiving_endpoint;
+ }
+ get sending() {
+ return this.selectedMessage.sending_endpoint;
+ }
+ get sentTime() {
+ return this.selectedMessage.time_sent ? new Date(this.selectedMessage.time_sent) : undefined;
+ }
+ get messageId() {
+ return this.selectedMessage.message_id;
+ }
+ get status() {
+ return this.selectedMessage.status;
+ }
+}
diff --git a/src/Frontend/src/resources/SequenceDiagram/SequenceModel.ts b/src/Frontend/src/resources/SequenceDiagram/SequenceModel.ts
new file mode 100644
index 000000000..db8313fc4
--- /dev/null
+++ b/src/Frontend/src/resources/SequenceDiagram/SequenceModel.ts
@@ -0,0 +1,142 @@
+import { NServiceBusHeaders } from "../Header";
+import Message from "../Message";
+import { createRoutedMessage, createRoute, MessageProcessingRoute } from "./RoutedMessage";
+import { createProcessingEndpoint, createSendingEndpoint, Endpoint, EndpointRegistry } from "./Endpoint";
+import { ConversationStartHandlerName, createProcessingHandler, createSendingHandler, Handler, HandlerRegistry, updateProcessingHandler } from "./Handler";
+
+export interface ConversationModel {
+ endpoints: Endpoint[];
+}
+
+//TODO: extract to common area if this continues to be used in AuditList
+export function friendlyTypeName(messageType: string) {
+ if (messageType == null) return undefined;
+
+ const typeClass = messageType.split(",")[0];
+ const typeName = typeClass.split(".").reverse()[0];
+ return typeName.replace(/\+/g, ".");
+}
+
+export class ModelCreator implements ConversationModel {
+ #endpoints: Endpoint[];
+ #handlers: Handler[];
+ #processingRoutes: MessageProcessingRoute[];
+
+ constructor(messages: Message[]) {
+ this.#endpoints = [];
+ this.#processingRoutes = [];
+
+ const endpointRegistry = new EndpointRegistry();
+ const handlerRegistry = new HandlerRegistry();
+ const firstOrderHandlers: Handler[] = [];
+ const messagesInOrder = MessageTreeNode.createTree(messages).flatMap((node) => node.walk());
+
+ // NOTE: All sending endpoints are created first to ensure version info is retained
+ for (const message of messagesInOrder) {
+ endpointRegistry.register(createSendingEndpoint(message));
+ }
+ for (const message of messagesInOrder) {
+ endpointRegistry.register(createProcessingEndpoint(message));
+ }
+
+ for (const message of messagesInOrder) {
+ const sendingEndpoint = endpointRegistry.get(createSendingEndpoint(message));
+ if (!this.#endpoints.find((endpoint) => endpoint.name === sendingEndpoint?.name)) {
+ this.#endpoints.push(sendingEndpoint);
+ }
+ const processingEndpoint = endpointRegistry.get(createProcessingEndpoint(message));
+ if (!this.#endpoints.find((endpoint) => endpoint.name === processingEndpoint?.name)) {
+ this.#endpoints.push(processingEndpoint);
+ }
+
+ const { handler: sendingHandler, isNew: sendingHandlerIsNew } = handlerRegistry.register(createSendingHandler(message, sendingEndpoint));
+ if (sendingHandlerIsNew) {
+ firstOrderHandlers.push(sendingHandler);
+ sendingEndpoint.addHandler(sendingHandler);
+ }
+ sendingHandler.updateProcessedAt(new Date(message.time_sent));
+
+ const { handler: processingHandler, isNew: processingHandlerIsNew } = handlerRegistry.register(createProcessingHandler(message, processingEndpoint));
+ if (processingHandlerIsNew) {
+ firstOrderHandlers.push(processingHandler);
+ processingEndpoint.addHandler(processingHandler);
+ } else {
+ updateProcessingHandler(processingHandler, message);
+ }
+
+ const routedMessage = createRoutedMessage(message);
+ routedMessage.toHandler = processingHandler;
+ routedMessage.fromHandler = sendingHandler;
+ this.#processingRoutes.push(createRoute(routedMessage, processingHandler));
+ processingHandler.inMessage = routedMessage;
+ sendingHandler.addOutMessage(routedMessage);
+ }
+
+ const start = firstOrderHandlers.find((h) => h.id === ConversationStartHandlerName);
+ const orderByHandledAt = firstOrderHandlers.filter((h) => h.id !== ConversationStartHandlerName).sort((a, b) => (a.handledAt?.getTime() ?? 0) - (b.handledAt?.getTime() ?? 0));
+
+ this.#handlers = [start!, ...orderByHandledAt];
+ }
+
+ get endpoints(): Endpoint[] {
+ return [...this.#endpoints];
+ }
+
+ get handlers(): Handler[] {
+ return [...this.#handlers];
+ }
+
+ get routes(): MessageProcessingRoute[] {
+ return [...this.#processingRoutes];
+ }
+}
+
+class MessageTreeNode {
+ #message: Message;
+ #parent?: string;
+ #children: MessageTreeNode[];
+
+ static createTree(messages: Message[]) {
+ const nodes = messages.map((message) => new MessageTreeNode(message));
+ const resolved: MessageTreeNode[] = [];
+ const index = new Map(nodes.map((node) => [node.id, node]));
+
+ for (const node of nodes) {
+ const parent = index.get(node.parent ?? "");
+ if (parent) {
+ parent.addChild(node);
+ resolved.push(node);
+ }
+ }
+
+ return nodes.filter((node) => !resolved.includes(node));
+ }
+
+ constructor(message: Message) {
+ this.#message = message;
+ this.#parent = message.headers.find((h) => h.key === NServiceBusHeaders.RelatedTo)?.value;
+ this.#children = [];
+ }
+
+ get id() {
+ return this.#message.message_id;
+ }
+ get parent() {
+ return this.#parent;
+ }
+ get message() {
+ return this.#message;
+ }
+ get children() {
+ return [...this.#children];
+ }
+
+ addChild(childNode: MessageTreeNode) {
+ this.#children.push(childNode);
+ }
+
+ walk(): Message[] {
+ //TODO: check performance of this. We may need to pre-calculate the processed_at as a date on the message object
+ return [this.#message, ...this.children.sort((a, b) => new Date(a.message.processed_at).getTime() - new Date(b.message.processed_at).getTime()).flatMap((child) => child.walk())];
+ }
+}
diff --git a/src/Frontend/src/stores/SequenceDiagramStore.ts b/src/Frontend/src/stores/SequenceDiagramStore.ts
new file mode 100644
index 000000000..e8b152d70
--- /dev/null
+++ b/src/Frontend/src/stores/SequenceDiagramStore.ts
@@ -0,0 +1,95 @@
+import { useFetchFromServiceControl } from "@/composables/serviceServiceControlUrls";
+import { acceptHMRUpdate, defineStore } from "pinia";
+import { ref, watch } from "vue";
+import { ModelCreator } from "@/resources/SequenceDiagram/SequenceModel";
+import Message from "@/resources/Message";
+import { Endpoint } from "@/resources/SequenceDiagram/Endpoint";
+import { Handler } from "@/resources/SequenceDiagram/Handler";
+import { MessageProcessingRoute } from "@/resources/SequenceDiagram/RoutedMessage";
+
+export interface EndpointCentrePoint {
+ name: string;
+ centre: number;
+ top: number;
+}
+
+export interface HandlerLocation {
+ id: string;
+ left: number;
+ right: number;
+ y: number;
+ height: number;
+}
+
+export const useSequenceDiagramStore = defineStore("SequenceDiagramStore", () => {
+ const conversationId = ref();
+
+ const endpoints = ref([]);
+ const handlers = ref([]);
+ const routes = ref([]);
+ const endpointCentrePoints = ref([]);
+ const maxWidth = ref(150);
+ const maxHeight = ref(150);
+ const handlerLocations = ref([]);
+ const highlightId = ref();
+
+ watch(conversationId, async () => {
+ if (!conversationId.value) return;
+ const response = await useFetchFromServiceControl(`conversations/${conversationId.value}`);
+ if (response.status === 404) {
+ return;
+ }
+
+ const model = new ModelCreator((await response.json()) as Message[]);
+ endpoints.value = model.endpoints;
+ handlers.value = model.handlers;
+ routes.value = model.routes;
+ });
+
+ function setConversationId(id: string) {
+ conversationId.value = id;
+ }
+
+ function setMaxWidth(width: number) {
+ maxWidth.value = width;
+ }
+
+ function setMaxHeight(height: number) {
+ maxHeight.value = height;
+ }
+
+ function setEndpointCentrePoints(centrePoints: EndpointCentrePoint[]) {
+ endpointCentrePoints.value = centrePoints;
+ }
+
+ function setHandlerLocations(locations: HandlerLocation[]) {
+ handlerLocations.value = locations;
+ }
+
+ function setHighlightId(id?: string) {
+ highlightId.value = id;
+ }
+
+ return {
+ setConversationId,
+ endpoints,
+ handlers,
+ routes,
+ endpointCentrePoints,
+ maxWidth,
+ maxHeight,
+ handlerLocations,
+ highlightId,
+ setMaxWidth,
+ setMaxHeight,
+ setEndpointCentrePoints,
+ setHandlerLocations,
+ setHighlightId,
+ };
+});
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useSequenceDiagramStore, import.meta.hot));
+}
+
+export type SequenceDiagramStore = ReturnType;