From 49b73381fcc93ba1c3b3844ce69d5910caf404c3 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 14 Apr 2025 15:40:17 -0700 Subject: [PATCH 1/8] rebase with master --- src/Frontend/src/assets/CommandIcon.svg | 5 + src/Frontend/src/assets/NoSaga.svg | 10 + src/Frontend/src/assets/SagaCompletedIcon.svg | 4 + src/Frontend/src/assets/SagaIcon.svg | 8 + src/Frontend/src/assets/SagaInitiatedIcon.svg | 4 + src/Frontend/src/assets/SagaTimeoutIcon.svg | 8 + src/Frontend/src/assets/SagaUpdatedIcon.svg | 5 + .../src/assets/Shell_CopyClipboard.svg | 14 + .../src/assets/Shell_ToolbarEndpoint.svg | 11 + src/Frontend/src/assets/TimeoutIcon.svg | 3 + src/Frontend/src/assets/saga.svg | 9 + .../components/messages2/SagaDiagram.spec.ts | 430 ++++++++++++ .../src/components/messages2/SagaDiagram.vue | 655 ++++++++++++++++++ src/Frontend/src/composables/typeHumanizer.ts | 11 + src/Frontend/src/resources/SagaHistory.ts | 35 + src/Frontend/src/stores/SagaDiagramStore.ts | 75 ++ src/Frontend/test/utils.ts | 2 +- 17 files changed, 1288 insertions(+), 1 deletion(-) create mode 100644 src/Frontend/src/assets/CommandIcon.svg create mode 100644 src/Frontend/src/assets/NoSaga.svg create mode 100644 src/Frontend/src/assets/SagaCompletedIcon.svg create mode 100644 src/Frontend/src/assets/SagaIcon.svg create mode 100644 src/Frontend/src/assets/SagaInitiatedIcon.svg create mode 100644 src/Frontend/src/assets/SagaTimeoutIcon.svg create mode 100644 src/Frontend/src/assets/SagaUpdatedIcon.svg create mode 100644 src/Frontend/src/assets/Shell_CopyClipboard.svg create mode 100644 src/Frontend/src/assets/Shell_ToolbarEndpoint.svg create mode 100644 src/Frontend/src/assets/TimeoutIcon.svg create mode 100644 src/Frontend/src/assets/saga.svg create mode 100644 src/Frontend/src/components/messages2/SagaDiagram.spec.ts create mode 100644 src/Frontend/src/components/messages2/SagaDiagram.vue create mode 100644 src/Frontend/src/composables/typeHumanizer.ts create mode 100644 src/Frontend/src/resources/SagaHistory.ts create mode 100644 src/Frontend/src/stores/SagaDiagramStore.ts diff --git a/src/Frontend/src/assets/CommandIcon.svg b/src/Frontend/src/assets/CommandIcon.svg new file mode 100644 index 000000000..ba45dd821 --- /dev/null +++ b/src/Frontend/src/assets/CommandIcon.svg @@ -0,0 +1,5 @@ + + + + diff --git a/src/Frontend/src/assets/NoSaga.svg b/src/Frontend/src/assets/NoSaga.svg new file mode 100644 index 000000000..7126d918b --- /dev/null +++ b/src/Frontend/src/assets/NoSaga.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/src/Frontend/src/assets/SagaCompletedIcon.svg b/src/Frontend/src/assets/SagaCompletedIcon.svg new file mode 100644 index 000000000..edf6156c9 --- /dev/null +++ b/src/Frontend/src/assets/SagaCompletedIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Frontend/src/assets/SagaIcon.svg b/src/Frontend/src/assets/SagaIcon.svg new file mode 100644 index 000000000..c37953fb4 --- /dev/null +++ b/src/Frontend/src/assets/SagaIcon.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/src/Frontend/src/assets/SagaInitiatedIcon.svg b/src/Frontend/src/assets/SagaInitiatedIcon.svg new file mode 100644 index 000000000..da9691b48 --- /dev/null +++ b/src/Frontend/src/assets/SagaInitiatedIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Frontend/src/assets/SagaTimeoutIcon.svg b/src/Frontend/src/assets/SagaTimeoutIcon.svg new file mode 100644 index 000000000..31eb4e0a8 --- /dev/null +++ b/src/Frontend/src/assets/SagaTimeoutIcon.svg @@ -0,0 +1,8 @@ + + + + diff --git a/src/Frontend/src/assets/SagaUpdatedIcon.svg b/src/Frontend/src/assets/SagaUpdatedIcon.svg new file mode 100644 index 000000000..04c878937 --- /dev/null +++ b/src/Frontend/src/assets/SagaUpdatedIcon.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/Frontend/src/assets/Shell_CopyClipboard.svg b/src/Frontend/src/assets/Shell_CopyClipboard.svg new file mode 100644 index 000000000..cdc8ef6e3 --- /dev/null +++ b/src/Frontend/src/assets/Shell_CopyClipboard.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/Frontend/src/assets/Shell_ToolbarEndpoint.svg b/src/Frontend/src/assets/Shell_ToolbarEndpoint.svg new file mode 100644 index 000000000..23c5003fc --- /dev/null +++ b/src/Frontend/src/assets/Shell_ToolbarEndpoint.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Frontend/src/assets/TimeoutIcon.svg b/src/Frontend/src/assets/TimeoutIcon.svg new file mode 100644 index 000000000..539c2a0cc --- /dev/null +++ b/src/Frontend/src/assets/TimeoutIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/Frontend/src/assets/saga.svg b/src/Frontend/src/assets/saga.svg new file mode 100644 index 000000000..f284fcc52 --- /dev/null +++ b/src/Frontend/src/assets/saga.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Frontend/src/components/messages2/SagaDiagram.spec.ts b/src/Frontend/src/components/messages2/SagaDiagram.spec.ts new file mode 100644 index 000000000..230fcf757 --- /dev/null +++ b/src/Frontend/src/components/messages2/SagaDiagram.spec.ts @@ -0,0 +1,430 @@ +import { render, describe, test, screen, expect, within } from "@component-test-utils"; +import sut from "../messages2/SagaDiagram.vue"; +import Message, { SagaInfo } from "@/resources/Message"; +import { SagaHistory } from "@/resources/SagaHistory"; +import makeRouter from "@/router"; +import { createTestingPinia } from "@pinia/testing"; + +//Defines a domain-specific language (DSL) for interacting with the system under test (sut) +interface componentDSL { + action1(value: string): void; + assert: componentDSLAssertions; +} + +//Defines a domain-specific language (DSL) for checking assertions against the system under test (sut) +interface componentDSLAssertions { + thereAreTheFollowingSagaChangesInThisOrder(sagaUpdates: { expectedRenderedLocalTime: string }[]): void; + displayedSagaGuidIs(sagaId: string): void; + displayedSagaNameIs(humanizedSagaName: string): void; + linkIsShown(arg0: { withText: string; withHref: string }): void; + NoSagaDataAvailableMessageIsShownWithMessage(message: RegExp): void; + SagaPlugInNeededIsShownWithTheMessages({ messages, withPluginDownloadUrl }: { messages: RegExp[]; withPluginDownloadUrl: string }): void; + SagaSequenceIsNotShown(): void; +} + +describe("Feature: Message not involved in Saga", () => { + describe("Rule: When the selected message has not participated in a Saga, display a legend indicating it.​", () => { + test("EXAMPLE: A message that has not participated in a saga is selected", () => { + const message = {} as Message; + message.invoked_sagas = []; + + const componentDriver = rendercomponent({ message: message }); + + componentDriver.assert.NoSagaDataAvailableMessageIsShownWithMessage(/no saga data/i); + }); + }); +}); + +describe("Feature: Detecting no Audited Saga Data Available", () => { + describe("Rule: When a message participates in a Saga, but the Saga data is unavailable, display a legend indicating that the Saga audit plugin is needed to visualize the saga.", () => { + test("EXAMPLE: A message that was participated in a Saga without the Saga audit plugin being active gets selected", () => { + const message = {} as Message; + const invokedSaga = {} as SagaInfo; + invokedSaga.saga_id = "saga_id"; + message.invoked_sagas = [invokedSaga]; + + // No need to manually set up the store - it will be empty by default + const componentDriver = rendercomponent({ + message: message, + // No initial state needed - we want an empty saga history + }); + + componentDriver.assert.SagaPlugInNeededIsShownWithTheMessages({ + messages: [/Saga audit plugin needed to visualize saga/i, /To visualize your saga, please install the appropriate nuget package in your endpoint/i, /install-package NServiceBus\.SagaAudit/i], + withPluginDownloadUrl: "https://www.nuget.org/packages/NServiceBus.SagaAudit", + }); + }); + }); +}); + +describe("Feature: Navigation and Contextual Information", () => { + describe("Rule: Provide clear navigational elements to move between the message flow diagram and the saga view.", () => { + test("EXAMPLE: A message record with id '123' and with a saga Id '88878' gets selected", () => { + //A "← Back to Messages" link allows users to easily navigate back to the flow diagram. + const message = {} as Message; + const invokedSaga = {} as SagaInfo; + invokedSaga.saga_id = "88878"; + message.invoked_sagas = [invokedSaga]; + + const storedMessageRecordId = "123"; + const message_id = "456"; + + message.id = storedMessageRecordId; + message.message_id = message_id; + + // Set initial state with sample saga history + const componentDriver = rendercomponent({ + message: message, + initialState: { + sagaHistory: { sagaHistory: sampleSagaHistory }, + }, + }); + + componentDriver.assert.linkIsShown({ withText: "← Back to Messages", withHref: `#/messages/${message_id}/${storedMessageRecordId}` }); + }); + }); + + describe("Rule: Clearly indicate contextual information like Saga ID and Saga Type.", () => { + test("EXAMPLE: A message with a Saga Id '123' and a Saga Type 'ServiceControl.SmokeTest.AuditingSaga' gets selected", () => { + const message = {} as Message; + const invokedSaga = {} as SagaInfo; + invokedSaga.saga_id = "123"; + invokedSaga.saga_type = "ServiceControl.SmokeTest.AuditingSaga"; + message.invoked_sagas = [invokedSaga]; + + // Set initial state with sample saga history + const componentDriver = rendercomponent({ + message: message, + initialState: { + sagaHistory: { sagaHistory: sampleSagaHistory }, + }, + }); + + componentDriver.assert.displayedSagaNameIs("AuditingSaga"); + componentDriver.assert.displayedSagaGuidIs("123"); + }); + }); +}); + +describe("Feature: 3 Visual Representation of Saga Timeline", () => { + describe("Rule: 3.1 Clearly indicate the initiation and completion of a saga.", () => { + test.todo("EXAMPLE: A message with a Saga Id '123' and a Saga Type 'ServiceControl.SmokeTest.AuditingSaga' gets selected", () => { + //"Saga Initiated" is explicitly displayed first, and "Saga Completed" is explicitly displayed at the bottom. + }); + }); + + describe("Rule: 3.2 Display a chronological timeline of saga events in UTC.", () => { + test("EXAMPLE: Rendering a Saga with 4 changes", () => { + // Each saga event ("Saga Initiated," "Saga Updated," "Timeout Invoked," "Saga Completed") is timestamped to represent progression over time. Events are ordered by the time they ocurred. + //TODO: "Incoming messages are displayed on the left, and outgoing messages are displayed on the right." in another test? + + //arragement + //sampleSagaHistory already not sorted TODO: Make this more clear so the reader of this test doesn't have to go arround and figure out the preconditions + const message = {} as Message; + const invokedSaga = {} as SagaInfo; + invokedSaga.saga_id = "123"; + invokedSaga.saga_type = "ServiceControl.SmokeTest.AuditingSaga"; + message.invoked_sagas = [invokedSaga]; + + // Set the environment to a fixed timezone + // JSDOM, used by Vitest, defaults to UTC timezone + // To ensure consistency, explicitly set the timezone to UTC + // This ensures that the rendered local time of the saga changes + // will always be interpreted and displayed in UTC, avoiding flakiness + process.env.TZ = "UTC"; + + //access each of the saga changes and update its start time and finish time to the same values being read from the variable declaration, + // but set them again explicitly here + //so that the reader of this test can see the preconditions at play + //and understand the test better without having to jump around + sampleSagaHistory.changes[0].start_time = new Date("2025-03-28T03:04:08.3819211Z"); // A + sampleSagaHistory.changes[0].finish_time = new Date("2025-03-28T03:04:08.3836Z"); // A1 + sampleSagaHistory.changes[1].start_time = new Date("2025-03-28T03:04:07.5416262Z"); // B + sampleSagaHistory.changes[1].finish_time = new Date("2025-03-28T03:04:07.5509712Z"); //B1 + sampleSagaHistory.changes[2].start_time = new Date("2025-03-28T03:04:06.3088353Z"); //C + sampleSagaHistory.changes[2].finish_time = new Date("2025-03-28T03:04:06.3218175Z"); //C1 + sampleSagaHistory.changes[3].start_time = new Date("2025-03-28T03:04:05.3332078Z"); //D + sampleSagaHistory.changes[3].finish_time = new Date("2025-03-28T03:04:05.3799483Z"); //D1 + sampleSagaHistory.changes[3].status = "new"; + + //B(1), C(2), A(0), D(3) + //B(1), C1(2), C(2), A1(0) + + // Set up the store with sample saga history + const componentDriver = rendercomponent({ + message: message, + initialState: { + sagaHistory: { sagaHistory: sampleSagaHistory }, + }, + }); + + //assert + + componentDriver.assert.thereAreTheFollowingSagaChangesInThisOrder([ + { + expectedRenderedLocalTime: "3/28/2025 3:04:05 AM", + }, + { + expectedRenderedLocalTime: "3/28/2025 3:04:06 AM", + }, + { + expectedRenderedLocalTime: "3/28/2025 3:04:07 AM", + }, + { + expectedRenderedLocalTime: "3/28/2025 3:04:08 AM", + }, + ]); + }); + }); + describe("Rule: 3.3 Display a chronological timeline of saga events in PST.", () => { + test("EXAMPLE: Rendering a Saga with 4 changes", () => { + // Each saga event ("Saga Initiated," "Saga Updated," "Timeout Invoked," "Saga Completed") is timestamped to represent progression over time. Events are ordered by the time they ocurred. + //TODO: "Incoming messages are displayed on the left, and outgoing messages are displayed on the right." in another test? + + //arragement + //sampleSagaHistory already not sorted TODO: Make this more clear so the reader of this test doesn't have to go arround and figure out the preconditions + const message = {} as Message; + const invokedSaga = {} as SagaInfo; + invokedSaga.saga_id = "123"; + invokedSaga.saga_type = "ServiceControl.SmokeTest.AuditingSaga"; + message.invoked_sagas = [invokedSaga]; + + // Set the environment to a fixed timezone + // JSDOM, used by Vitest, defaults to PST timezone + // To ensure consistency, explicitly set the timezone to PST + // This ensures that the rendered local time of the saga changes + // will always be interpreted and displayed in PST, avoiding flakiness + process.env.TZ = "PST"; + + //access each of the saga changes and update its start time and finish time to the same values being read from the variable declaration, + // but set them again explicitly here + //so that the reader of this test can see the preconditions at play + //and understand the test better without having to jump around + sampleSagaHistory.changes[0].start_time = new Date("2025-03-28T03:04:08.3819211Z"); // A + sampleSagaHistory.changes[0].finish_time = new Date("2025-03-28T03:04:08.3836Z"); // A1 + sampleSagaHistory.changes[1].start_time = new Date("2025-03-28T03:04:07.5416262Z"); // B + sampleSagaHistory.changes[1].finish_time = new Date("2025-03-28T03:04:07.5509712Z"); //B1 + sampleSagaHistory.changes[2].start_time = new Date("2025-03-28T03:04:06.3088353Z"); //C + sampleSagaHistory.changes[2].finish_time = new Date("2025-03-28T03:04:06.3218175Z"); //C1 + sampleSagaHistory.changes[3].start_time = new Date("2025-03-28T03:04:05.3332078Z"); //D + sampleSagaHistory.changes[3].finish_time = new Date("2025-03-28T03:04:05.3799483Z"); //D1 + sampleSagaHistory.changes[3].status = "new"; + + //B(1), C(2), A(0), D(3) + //B(1), C1(2), C(2), A1(0) + + // Set up the store with sample saga history + const componentDriver = rendercomponent({ + message: message, + initialState: { + sagaHistory: { sagaHistory: sampleSagaHistory }, + }, + }); + + //assert + + componentDriver.assert.thereAreTheFollowingSagaChangesInThisOrder([ + { + expectedRenderedLocalTime: "3/27/2025 8:04:05 PM", + }, + { + expectedRenderedLocalTime: "3/27/2025 8:04:06 PM", + }, + { + expectedRenderedLocalTime: "3/27/2025 8:04:07 PM", + }, + { + expectedRenderedLocalTime: "3/27/2025 8:04:08 PM", + }, + ]); + }); + }); +}); + +function rendercomponent({ message, initialState = {} }: { message: Message; initialState?: { sagaHistory?: { sagaHistory: SagaHistory } } }): componentDSL { + const router = makeRouter(); + + // Render with createTestingPinia + render(sut, { + props: { + message, + }, + global: { + plugins: [ + router, + createTestingPinia({ + initialState, + stubActions: true, // Explicitly stub actions (this is the default) + }), + ], + }, + }); + + const dslAPI: componentDSL = { + action1: () => { + // Add actions here;dl;;lksd;lksd;lkdmdslm,.mc,. + }, + assert: { + NoSagaDataAvailableMessageIsShownWithMessage(message: RegExp) { + //ensure that the only one status message is shown + expect(screen.queryAllByRole("status")).toHaveLength(1); + + const status = screen.queryByRole("status", { name: /message-not-involved-in-saga/i }); + expect(status).toBeInTheDocument(); + const statusText = within(status!).getByText(message); + expect(statusText).toBeInTheDocument(); + + this.SagaSequenceIsNotShown(); + }, + SagaPlugInNeededIsShownWithTheMessages({ messages, withPluginDownloadUrl }: { messages: RegExp[]; withPluginDownloadUrl: string }) { + // Use the matcher to find the container element + const messageContainer = screen.queryByRole("status", { name: /saga-plugin-needed/i }); + expect(messageContainer).toBeInTheDocument(); + + // using within to find the text inside the container per each item in messages + messages.forEach((message) => { + const statusText = within(messageContainer!).getByText(message); + expect(statusText).toBeInTheDocument(); + }); + + // Verify the link + const link = screen.getByRole("link", { name: "install-package NServiceBus.SagaAudit" }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", withPluginDownloadUrl); + + this.SagaSequenceIsNotShown(); + }, + SagaSequenceIsNotShown() { + const sagaSequence = screen.queryByRole("list", { name: /saga-sequence-list/i }); + expect(sagaSequence).not.toBeInTheDocument(); + }, + linkIsShown(args: { withText: string; withHref: string }) { + const link = screen.getByRole("link", { name: args.withText }); + expect(link).toBeInTheDocument(); + expect(link.getAttribute("href")).toBe(args.withHref); + }, + displayedSagaNameIs(name: string) { + const sagaName = screen.getByRole("heading", { name: /saga name/i }); + expect(sagaName).toBeInTheDocument(); + expect(sagaName).toHaveTextContent(name); + }, + displayedSagaGuidIs(guid: string) { + const sagaGuid = screen.getByRole("note", { name: /saga guid/i }); + expect(sagaGuid).toBeInTheDocument(); + expect(sagaGuid).toHaveTextContent(guid); + }, + thereAreTheFollowingSagaChangesInThisOrder: function (sagaUpdates: { expectedRenderedLocalTime: string }[]): void { + //Retrive the main parent component that contains the saga changes + const sagaChangesContainer = screen.getByRole("table", { name: /saga-sequence-list/i }); + + const sagaUpdatesElements = within(sagaChangesContainer).queryAllByRole("row"); + //from within each sagaUpdatesElemtns get the values of an element with aria-label="time stamp" + //and check if the values are in the same order as the sagaUpdates array passed to this function + const sagaUpdatesTimestamps = sagaUpdatesElements.map((item: HTMLElement) => within(item).getByLabelText("time stamp")); + + //expect the number of found sagaUpdatesTimestamps to be the same as the number of sagaUpdates passed to this function + expect(sagaUpdatesTimestamps).toHaveLength(sagaUpdates.length); + + const sagaUpdatesTimestampsValues = sagaUpdatesTimestamps.map((item) => item.innerHTML); + // //check if the values are in the same order as the sagaUpdates array passed to this function + expect(sagaUpdatesTimestampsValues).toEqual(sagaUpdates.map((item) => item.expectedRenderedLocalTime)); + }, + }, + }; + + return dslAPI; +} + +const sampleSagaHistory: SagaHistory = { + id: "45f425fc-26ce-163b-4f64-857b889348f3", + saga_id: "45f425fc-26ce-163b-4f64-857b889348f3", + saga_type: "ServiceControl.SmokeTest.AuditingSaga", + changes: [ + { + start_time: new Date("2025-03-28T03:04:08.3819211Z"), + finish_time: new Date("2025-03-28T03:04:08.3836Z"), + status: "completed", + state_after_change: '{"Id":"45f425fc-26ce-163b-4f64-857b889348f3","Originator":null,"OriginalMessageId":"4b9fdea7-d78c-41f0-91ee-b2ae00328f9c"}', + initiating_message: { + message_id: "876d89bd-7a1f-43f1-b384-b2ae003290e8", + is_saga_timeout_message: true, + originating_endpoint: "Endpoint1", + originating_machine: "mobvm2", + time_sent: new Date("2025-03-28T03:04:06.321561Z"), + message_type: "ServiceControl.SmokeTest.MyCustomTimeout", + intent: "Send", + }, + outgoing_messages: [], + endpoint: "Endpoint1", + }, + { + start_time: new Date("2025-03-28T03:04:07.5416262Z"), + finish_time: new Date("2025-03-28T03:04:07.5509712Z"), + status: "updated", + state_after_change: '{"Id":"45f425fc-26ce-163b-4f64-857b889348f3","Originator":null,"OriginalMessageId":"4b9fdea7-d78c-41f0-91ee-b2ae00328f9c"}', + initiating_message: { + message_id: "1308367f-c6a2-418f-9df2-b2ae00328fc9", + is_saga_timeout_message: true, + originating_endpoint: "Endpoint1", + originating_machine: "mobvm2", + time_sent: new Date("2025-03-28T03:04:05.37723Z"), + message_type: "ServiceControl.SmokeTest.MyCustomTimeout", + intent: "Send", + }, + outgoing_messages: [], + endpoint: "Endpoint1", + }, + { + start_time: new Date("2025-03-28T03:04:06.3088353Z"), + finish_time: new Date("2025-03-28T03:04:06.3218175Z"), + status: "updated", + state_after_change: '{"Id":"45f425fc-26ce-163b-4f64-857b889348f3","Originator":null,"OriginalMessageId":"4b9fdea7-d78c-41f0-91ee-b2ae00328f9c"}', + initiating_message: { + message_id: "e5bb5304-7892-4d39-96e2-b2ae003290df", + is_saga_timeout_message: false, + originating_endpoint: "Sender", + originating_machine: "mobvm2", + time_sent: new Date("2025-03-28T03:04:06.293765Z"), + message_type: "ServiceControl.SmokeTest.SagaMessage2", + intent: "Send", + }, + outgoing_messages: [ + { + delivery_delay: "00:00:02", + destination: "Endpoint1", + message_id: "876d89bd-7a1f-43f1-b384-b2ae003290e8", + time_sent: new Date("2025-03-28T03:04:06.3214397Z"), + message_type: "ServiceControl.SmokeTest.MyCustomTimeout", + intent: "Send", + }, + ], + endpoint: "Endpoint1", + }, + { + start_time: new Date("2025-03-28T03:04:05.3332078Z"), + finish_time: new Date("2025-03-28T03:04:05.3799483Z"), + status: "new", + state_after_change: '{"Id":"45f425fc-26ce-163b-4f64-857b889348f3","Originator":null,"OriginalMessageId":"4b9fdea7-d78c-41f0-91ee-b2ae00328f9c"}', + initiating_message: { + message_id: "4b9fdea7-d78c-41f0-91ee-b2ae00328f9c", + is_saga_timeout_message: false, + originating_endpoint: "Sender", + originating_machine: "mobvm2", + time_sent: new Date("2025-03-28T03:04:05.235534Z"), + message_type: "ServiceControl.SmokeTest.SagaMessage1", + intent: "Send", + }, + outgoing_messages: [ + { + delivery_delay: "00:00:02", + destination: "Endpoint1", + message_id: "1308367f-c6a2-418f-9df2-b2ae00328fc9", + time_sent: new Date("2025-03-28T03:04:05.3715034Z"), + message_type: "ServiceControl.SmokeTest.MyCustomTimeout", + intent: "Send", + }, + ], + endpoint: "Endpoint1", + }, + ], +}; diff --git a/src/Frontend/src/components/messages2/SagaDiagram.vue b/src/Frontend/src/components/messages2/SagaDiagram.vue new file mode 100644 index 000000000..13023e10d --- /dev/null +++ b/src/Frontend/src/components/messages2/SagaDiagram.vue @@ -0,0 +1,655 @@ + + + + + diff --git a/src/Frontend/src/composables/typeHumanizer.ts b/src/Frontend/src/composables/typeHumanizer.ts new file mode 100644 index 000000000..c31155541 --- /dev/null +++ b/src/Frontend/src/composables/typeHumanizer.ts @@ -0,0 +1,11 @@ +export function typeToName(type: string | null | undefined): string | null { + if (!type) { + return null; + } + + const className = type.split(",")[0]; + let objectName = className.split(".").pop() || ""; + objectName = objectName.replace(/\+/g, "."); + + return objectName; +} diff --git a/src/Frontend/src/resources/SagaHistory.ts b/src/Frontend/src/resources/SagaHistory.ts new file mode 100644 index 000000000..bb3d8e75f --- /dev/null +++ b/src/Frontend/src/resources/SagaHistory.ts @@ -0,0 +1,35 @@ +export interface SagaHistory { + id: string; + saga_id: string; + saga_type: string; + changes: SagaStateChange[]; +} + +export interface SagaStateChange { + start_time: Date; + finish_time: Date; + status: string; + state_after_change: string; + initiating_message: InitiatingMessage; + outgoing_messages: OutgoingMessage[]; + endpoint: string; +} + +export interface InitiatingMessage { + message_id: string; + is_saga_timeout_message: boolean; + originating_endpoint: string; + originating_machine: string; + time_sent: Date; + message_type: string; + intent: string; +} + +export interface OutgoingMessage { + delivery_delay?: string; + destination: string; + message_id: string; + time_sent: Date; + message_type: string; + intent: string; +} diff --git a/src/Frontend/src/stores/SagaDiagramStore.ts b/src/Frontend/src/stores/SagaDiagramStore.ts new file mode 100644 index 000000000..e99ddccce --- /dev/null +++ b/src/Frontend/src/stores/SagaDiagramStore.ts @@ -0,0 +1,75 @@ +import { acceptHMRUpdate, defineStore } from "pinia"; +import { ref, watch } from "vue"; +import { SagaHistory } from "@/resources/SagaHistory"; +import { useFetchFromServiceControl } from "@/composables/serviceServiceControlUrls"; + +export const useSagaDiagramStore = defineStore("sagaHistory", () => { + const sagaHistory = ref(null); + const sagaId = ref(null); + const loading = ref(false); + const error = ref(null); + + // Watch for changes to sagaId and fetch saga history data + watch(sagaId, async (newSagaId) => { + if (!newSagaId) { + sagaHistory.value = null; + return; + } + + await fetchSagaHistory(newSagaId); + }); + + function setSagaId(id: string | null) { + sagaId.value = id; + } + + async function fetchSagaHistory(id: string) { + if (!id) return; + + loading.value = true; + error.value = null; + + try { + const response = await useFetchFromServiceControl(`sagas/${id}`); + + if (response.status === 404) { + sagaHistory.value = null; + error.value = "Saga history not found"; + } else if (!response.ok) { + sagaHistory.value = null; + error.value = "Failed to fetch saga history"; + } else { + const data = await response.json(); + + sagaHistory.value = data; + } + } catch (e) { + error.value = e instanceof Error ? e.message : "Unknown error occurred"; + sagaHistory.value = null; + } finally { + loading.value = false; + } + } + + function clearSagaHistory() { + sagaHistory.value = null; + sagaId.value = null; + error.value = null; + } + + return { + sagaHistory, + sagaId, + loading, + error, + setSagaId, + fetchSagaHistory, + clearSagaHistory, + }; +}); + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useSagaDiagramStore, import.meta.hot)); +} + +export type SagaDiagramStore = ReturnType; diff --git a/src/Frontend/test/utils.ts b/src/Frontend/test/utils.ts index eaf339dc3..6d8b987d8 100644 --- a/src/Frontend/test/utils.ts +++ b/src/Frontend/test/utils.ts @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event"; import { mockServer } from "./mock-server"; import { Driver } from "./driver"; -export { render, screen } from "@testing-library/vue"; +export { render, screen, within } from "@testing-library/vue"; export { expect, test, describe } from "vitest"; export { userEvent }; From 6117adf75c4a257da69a82a27eb4e782cb5b0197 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 15 Apr 2025 14:41:55 -0700 Subject: [PATCH 2/8] updated html and styles --- .../src/components/messages2/SagaDiagram.vue | 138 ++++++++++++------ 1 file changed, 92 insertions(+), 46 deletions(-) diff --git a/src/Frontend/src/components/messages2/SagaDiagram.vue b/src/Frontend/src/components/messages2/SagaDiagram.vue index 13023e10d..5ff1765fb 100644 --- a/src/Frontend/src/components/messages2/SagaDiagram.vue +++ b/src/Frontend/src/components/messages2/SagaDiagram.vue @@ -13,6 +13,7 @@ import EventIcon from "@/assets/event.svg"; import TimeoutIcon from "@/assets/TimeoutIcon.svg"; import SagaIcon from "@/assets/SagaIcon.svg"; import SagaInitiatedIcon from "@/assets/SagaInitiatedIcon.svg"; +import SagaUpdatedIcon from "@/assets/SagaUpdatedIcon.svg"; import SagaCompletedIcon from "@/assets/SagaCompletedIcon.svg"; import SagaTimeoutIcon from "@/assets/SagaTimeoutIcon.svg"; import ToolbarEndpointIcon from "@/assets/Shell_ToolbarEndpoint.svg"; @@ -179,7 +180,7 @@ const vm = computed(() => {
- +
@@ -213,7 +214,7 @@ const vm = computed(() => {
-
+
← Back to Messages

{{ vm.SagaTitle }}

@@ -224,6 +225,7 @@ const vm = computed(() => {
+
@@ -235,8 +237,8 @@ const vm = computed(() => {
-
- +
+

{{ update.StatusDisplay }}

{{ update.FormattedStartTime }}
@@ -247,81 +249,96 @@ const vm = computed(() => {
-
- Property Y = Sample value +
+ +
+ OrderId + = + Sample ID +
- +
-
+
- +
  • - Prop 1 (new) + Property (new) = - sample property value + Sample Value
- - -
-
+
- -
-
-
-
-
-
-
- -

{{ msg.MessageFriendlyTypeName }}

-
{{ msg.FormattedTimeSent }}
+ +
-
+

Saga Completed

-
{{ vm.FormattedCompletionTime }}
+
{{ vm.FormattedCompletionTime }}
@@ -394,7 +411,8 @@ const vm = computed(() => { width: 1.5rem; } .container { - /* width: 66.6667%; */ + width: 66.6667%; + min-width: 50rem; } .block { /* border: solid 1px lightgreen; */ @@ -443,10 +461,11 @@ const vm = computed(() => { } .cell--top-border { - /* align-self: flex-start; */ + display: flex; + flex-direction: column; } .cell-inner { - padding: 0.5rem; + /* padding: 0.5rem; */ } .cell-inner-top { border-top: solid 2px #000000; @@ -454,17 +473,28 @@ const vm = computed(() => { } .cell-inner-center { padding: 0.5rem; +} +.cell-inner-line { + flex-grow: 1; + padding: 0.25rem 0.5rem; border-left: solid 2px #000000; margin-left: 1rem; } +.cell-inner-line:first-child { + flex-grow: 1; +} .cell-inner-center:first-child { flex-grow: 1; } .cell-inner-side { + margin-top: 1rem; padding: 0.25rem 0.25rem 0; border: solid 2px #cccccc; background-color: #cccccc; } +.cell-inner-side:nth-child(-n + 2) { + margin-top: 0; +} .cell-inner-side--active { border: solid 2px #000000; } @@ -495,6 +525,10 @@ const vm = computed(() => { /* Content styles */ +* { + /* font-family: Verdana, Geneva, Tahoma, sans-serif; */ +} + .saga-top-logo { margin-bottom: 0.5rem; color: #00a3c4; @@ -605,23 +639,35 @@ const vm = computed(() => { display: inline-block; text-overflow: ellipsis; } +.saga-properties-list-text:last-child { + padding-right: 0; + text-overflow: ellipsis; +} .timeout-status { display: inline-block; - margin-top: 0.7rem; font-size: 1rem; font-weight: 900; } -.message-data-box { - display: flex; - margin-bottom: 1rem; +.message-data { + display: none; padding: 0.2rem; background-color: #ffffff; border: solid 1px #cccccc; font-size: 0.75rem; } +.message-data--active { + display: block; +} +.message-data-box { + display: flex; +} .message-data-box-text { + display: inline-block; + margin-right: 0.25rem; +} +.message-data-box-text--ellipsis { display: inline-block; overflow: hidden; max-width: 100%; From b160456124258d16c4f06363787892d5c7b198e0 Mon Sep 17 00:00:00 2001 From: Jayanthi Date: Tue, 15 Apr 2025 15:54:18 -0700 Subject: [PATCH 3/8] Updated to read from message store --- .../src/components/messages2/MessageView.vue | 5 +++ .../src/components/messages2/SagaDiagram.vue | 35 ++++++++----------- src/Frontend/src/stores/MessageStore.ts | 17 ++++++--- 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/Frontend/src/components/messages2/MessageView.vue b/src/Frontend/src/components/messages2/MessageView.vue index e583fa24e..2aafb82a7 100644 --- a/src/Frontend/src/components/messages2/MessageView.vue +++ b/src/Frontend/src/components/messages2/MessageView.vue @@ -21,6 +21,7 @@ import { storeToRefs } from "pinia"; import MetadataLabel from "@/components/messages2/MetadataLabel.vue"; import { hexToCSSFilter } from "hex-to-css-filter"; import LoadingOverlay from "@/components/LoadingOverlay.vue"; +import SagaDiagram from "./SagaDiagram.vue"; const route = useRoute(); const id = computed(() => route.params.id as string); @@ -58,6 +59,10 @@ const tabs = computed(() => { text: "Sequence Diagram", component: SequenceDiagram, }); + currentTabs.push({ + text: "Saga Diagram", + component: SagaDiagram, + }); } return currentTabs; diff --git a/src/Frontend/src/components/messages2/SagaDiagram.vue b/src/Frontend/src/components/messages2/SagaDiagram.vue index 5ff1765fb..632231635 100644 --- a/src/Frontend/src/components/messages2/SagaDiagram.vue +++ b/src/Frontend/src/components/messages2/SagaDiagram.vue @@ -1,5 +1,4 @@ @@ -80,7 +81,7 @@ defineProps<{
- +
diff --git a/src/Frontend/src/components/messages2/SagaDiagram/useSagaDiagramParser.ts b/src/Frontend/src/components/messages2/SagaDiagram/useSagaDiagramParser.ts index 25c6f3e81..0814c3d5f 100644 --- a/src/Frontend/src/components/messages2/SagaDiagram/useSagaDiagramParser.ts +++ b/src/Frontend/src/components/messages2/SagaDiagram/useSagaDiagramParser.ts @@ -44,6 +44,7 @@ export interface SagaViewModel { SagaCompleted: boolean; FormattedCompletionTime: string; SagaUpdates: SagaUpdateViewModel[]; + ShowMessageData: boolean; } export function parseSagaUpdates(sagaHistory: SagaHistory | null): SagaUpdateViewModel[] { From 96ff5e6031e9c3680a4e3716d54b6aefbf59aefc Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 15 Apr 2025 22:37:42 -0700 Subject: [PATCH 8/8] cleaner viewmodel assigments --- .../src/components/messages2/SagaDiagram.vue | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/Frontend/src/components/messages2/SagaDiagram.vue b/src/Frontend/src/components/messages2/SagaDiagram.vue index 85ed2e6f1..1edd63fd0 100644 --- a/src/Frontend/src/components/messages2/SagaDiagram.vue +++ b/src/Frontend/src/components/messages2/SagaDiagram.vue @@ -6,7 +6,9 @@ import { useMessageStore } from "@/stores/MessageStore"; import { storeToRefs } from "pinia"; import ToolbarEndpointIcon from "@/assets/Shell_ToolbarEndpoint.svg"; import { SagaViewModel, parseSagaUpdates } from "./SagaDiagram/useSagaDiagramParser"; +import { typeToName } from "@/composables/typeHumanizer"; +//Subcomponents import NoSagaData from "./SagaDiagram/NoSagaData.vue"; import SagaPluginNeeded from "./SagaDiagram/SagaPluginNeeded.vue"; import SagaHeader from "./SagaDiagram/SagaHeader.vue"; @@ -45,22 +47,30 @@ const vm = computed(() => { const completedUpdate = sagaDiagramStore.sagaHistory?.changes.find((update) => update.status === "completed"); const completionTime = completedUpdate ? new Date(completedUpdate.finish_time) : null; + const { data } = messageState.value; + const { invoked_saga: saga } = data; + const sagaHistory = sagaDiagramStore.sagaHistory; + return { - SagaTitle: typeToName(messageState.value.data.invoked_saga.saga_type) || "Unknown saga", - SagaGuid: messageState.value.data.invoked_saga.saga_id || "Missing guid", - MessageIdUrl: messageState.value && routeLinks.messages.successMessage.link(messageState.value.data.message_id || "", messageState.value.data.id || ""), - ParticipatedInSaga: messageState.value.data.invoked_saga.has_saga || false, - HasSagaData: !!sagaDiagramStore.sagaHistory, - ShowNoPluginActiveLegend: (!sagaDiagramStore.sagaHistory && messageState.value.data.invoked_saga.has_saga) || false, + // Saga metadata + SagaTitle: typeToName(saga.saga_type) || "Unknown saga", + SagaGuid: saga.saga_id || "Missing guid", + + // Navigation + MessageIdUrl: routeLinks.messages.successMessage.link(data.message_id || "", data.id || ""), + + // Status flags + ParticipatedInSaga: saga.has_saga || false, + HasSagaData: !!sagaHistory, + ShowNoPluginActiveLegend: (!sagaHistory && saga.has_saga) || false, SagaCompleted: !!completedUpdate, + + // Display data FormattedCompletionTime: completionTime ? `${completionTime.toLocaleDateString()} ${completionTime.toLocaleTimeString()}` : "", - SagaUpdates: parseSagaUpdates(sagaDiagramStore.sagaHistory), + SagaUpdates: parseSagaUpdates(sagaHistory), ShowMessageData: showMessageData.value, }; }); - -// Import typeToName for saga title -import { typeToName } from "@/composables/typeHumanizer";