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/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.spec.ts b/src/Frontend/src/components/messages2/SagaDiagram.spec.ts new file mode 100644 index 000000000..4731842a6 --- /dev/null +++ b/src/Frontend/src/components/messages2/SagaDiagram.spec.ts @@ -0,0 +1,457 @@ +import { render, describe, test, screen, expect, within } from "@component-test-utils"; +import sut from "../messages2/SagaDiagram.vue"; +import { SagaHistory } from "@/resources/SagaHistory"; +import makeRouter from "@/router"; +import { createTestingPinia } from "@pinia/testing"; +import { MessageStore } from "@/stores/MessageStore"; + +//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 messageStore = {} as MessageStore; + messageStore.state = {} as MessageStore["state"]; + messageStore.state.data = {} as MessageStore["state"]["data"]; + messageStore.state.data.invoked_saga = { + has_saga: false, + saga_id: undefined, + saga_type: undefined, + }; + + // No need to manually set up the store - it will be empty by default + const componentDriver = rendercomponent({ + initialState: { + MessageStore: messageStore, + sagaHistory: undefined, // Lets pass undefined to simulate no saga data available + }, + }); + + 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 messageStore = {} as MessageStore; + messageStore.state = {} as MessageStore["state"]; + messageStore.state.data = {} as MessageStore["state"]["data"]; + messageStore.state.data.invoked_saga = { + has_saga: true, + saga_id: "saga-id-123", + saga_type: "Shipping.ShipOrderWorkflow", + }; + + // No need to manually set up the store - it will be empty by default + const componentDriver = rendercomponent({ + initialState: { + MessageStore: messageStore, + sagaHistory: undefined, // Lets pass undefined to simulate no saga data available + }, + }); + + 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 storedMessageRecordId = "123"; + const message_id = "456"; + + const messageStore = {} as MessageStore; + messageStore.state = {} as MessageStore["state"]; + messageStore.state.data = {} as MessageStore["state"]["data"]; + messageStore.state.data.message_id = message_id; + messageStore.state.data.id = storedMessageRecordId; + messageStore.state.data.invoked_saga = { + has_saga: true, + saga_id: "saga-id-123", + saga_type: "Shipping.ShipOrderWorkflow", + }; + + // Set initial state with sample saga history + const componentDriver = rendercomponent({ + initialState: { + MessageStore: messageStore, + 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 messageStore = {} as MessageStore; + messageStore.state = {} as MessageStore["state"]; + messageStore.state.data = {} as MessageStore["state"]["data"]; + messageStore.state.data.invoked_saga = { + has_saga: true, + saga_id: "123", + saga_type: "ServiceControl.SmokeTest.AuditingSaga", + }; + + // Set initial state with sample saga history + const componentDriver = rendercomponent({ + initialState: { + MessageStore: messageStore, + 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 messageStore = {} as MessageStore; + messageStore.state = {} as MessageStore["state"]; + messageStore.state.data = {} as MessageStore["state"]["data"]; + messageStore.state.data.invoked_saga = { + has_saga: true, + saga_id: "123", + saga_type: "ServiceControl.SmokeTest.AuditingSaga", + }; + + // 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({ + initialState: { + MessageStore: messageStore, + 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 messageStore = {} as MessageStore; + messageStore.state = {} as MessageStore["state"]; + messageStore.state.data = {} as MessageStore["state"]["data"]; + messageStore.state.data.invoked_saga = { + has_saga: true, + saga_id: "123", + saga_type: "ServiceControl.SmokeTest.AuditingSaga", + }; + + // 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({ + initialState: { + MessageStore: messageStore, + 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({ initialState = {} }: { initialState?: { MessageStore?: MessageStore; sagaHistory?: { sagaHistory: SagaHistory } } }): componentDSL { + const router = makeRouter(); + + // Render with createTestingPinia + render(sut, { + 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..1edd63fd0 --- /dev/null +++ b/src/Frontend/src/components/messages2/SagaDiagram.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/src/Frontend/src/components/messages2/SagaDiagram/MessageDataBox.vue b/src/Frontend/src/components/messages2/SagaDiagram/MessageDataBox.vue new file mode 100644 index 000000000..ea3a3a5a8 --- /dev/null +++ b/src/Frontend/src/components/messages2/SagaDiagram/MessageDataBox.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/Frontend/src/components/messages2/SagaDiagram/NoSagaData.vue b/src/Frontend/src/components/messages2/SagaDiagram/NoSagaData.vue new file mode 100644 index 000000000..f8d19b116 --- /dev/null +++ b/src/Frontend/src/components/messages2/SagaDiagram/NoSagaData.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/src/Frontend/src/components/messages2/SagaDiagram/SagaCompletedNode.vue b/src/Frontend/src/components/messages2/SagaDiagram/SagaCompletedNode.vue new file mode 100644 index 000000000..3727ae9f0 --- /dev/null +++ b/src/Frontend/src/components/messages2/SagaDiagram/SagaCompletedNode.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/src/Frontend/src/components/messages2/SagaDiagram/SagaHeader.vue b/src/Frontend/src/components/messages2/SagaDiagram/SagaHeader.vue new file mode 100644 index 000000000..6ed2a4589 --- /dev/null +++ b/src/Frontend/src/components/messages2/SagaDiagram/SagaHeader.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/src/Frontend/src/components/messages2/SagaDiagram/SagaPluginNeeded.vue b/src/Frontend/src/components/messages2/SagaDiagram/SagaPluginNeeded.vue new file mode 100644 index 000000000..a275bef75 --- /dev/null +++ b/src/Frontend/src/components/messages2/SagaDiagram/SagaPluginNeeded.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/src/Frontend/src/components/messages2/SagaDiagram/SagaTimeoutMessage.vue b/src/Frontend/src/components/messages2/SagaDiagram/SagaTimeoutMessage.vue new file mode 100644 index 000000000..ed253fca7 --- /dev/null +++ b/src/Frontend/src/components/messages2/SagaDiagram/SagaTimeoutMessage.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/src/Frontend/src/components/messages2/SagaDiagram/SagaUpdateNode.vue b/src/Frontend/src/components/messages2/SagaDiagram/SagaUpdateNode.vue new file mode 100644 index 000000000..26bc94ae9 --- /dev/null +++ b/src/Frontend/src/components/messages2/SagaDiagram/SagaUpdateNode.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/src/Frontend/src/components/messages2/SagaDiagram/useSagaDiagramParser.ts b/src/Frontend/src/components/messages2/SagaDiagram/useSagaDiagramParser.ts new file mode 100644 index 000000000..0814c3d5f --- /dev/null +++ b/src/Frontend/src/components/messages2/SagaDiagram/useSagaDiagramParser.ts @@ -0,0 +1,112 @@ +import { SagaHistory } from "@/resources/SagaHistory"; +import { typeToName } from "@/composables/typeHumanizer"; + +export interface SagaMessageDataItem { + Key: string; + Value: string; +} + +export interface SagaMessage { + MessageFriendlyTypeName: string; + FormattedTimeSent: string; + Data: SagaMessageDataItem[]; + IsEventMessage: boolean; + IsCommandMessage: boolean; +} + +export interface SagaTimeoutMessage extends SagaMessage { + TimeoutFriendly: string; +} + +export interface SagaUpdateViewModel { + StartTime: Date; + FinishTime: Date; + FormattedStartTime: string; + InitiatingMessageType: string; + FormattedInitiatingMessageTimestamp: string; + Status: string; + StatusDisplay: string; + HasTimeout: boolean; + IsFirstNode: boolean; + NonTimeoutMessages: SagaMessage[]; + TimeoutMessages: SagaTimeoutMessage[]; + HasNonTimeoutMessages: boolean; + HasTimeoutMessages: boolean; +} + +export interface SagaViewModel { + SagaTitle: string; + SagaGuid: string; + MessageIdUrl: string; + ParticipatedInSaga: boolean; + HasSagaData: boolean; + ShowNoPluginActiveLegend: boolean; + SagaCompleted: boolean; + FormattedCompletionTime: string; + SagaUpdates: SagaUpdateViewModel[]; + ShowMessageData: boolean; +} + +export function parseSagaUpdates(sagaHistory: SagaHistory | null): SagaUpdateViewModel[] { + if (!sagaHistory || !sagaHistory.changes || !sagaHistory.changes.length) return []; + + return sagaHistory.changes + .map((update) => { + const startTime = new Date(update.start_time); + const finishTime = new Date(update.finish_time); + const initiatingMessageTimestamp = new Date(update.initiating_message?.time_sent || Date.now()); + + // Create common base message objects with shared properties + const outgoingMessages = update.outgoing_messages.map((msg) => { + const delivery_delay = msg.delivery_delay || "00:00:00"; + const timeSent = new Date(msg.time_sent); + const hasTimeout = !!delivery_delay && delivery_delay !== "00:00:00"; + const timeoutSeconds = delivery_delay.split(":")[2] || "0"; + const isEventMessage = msg.intent === "Publish"; + + return { + MessageType: msg.message_type || "", + MessageId: msg.message_id, + FormattedTimeSent: `${timeSent.toLocaleDateString()} ${timeSent.toLocaleTimeString()}`, + HasTimeout: hasTimeout, + TimeoutSeconds: timeoutSeconds, + MessageFriendlyTypeName: typeToName(msg.message_type || ""), + Data: [] as SagaMessageDataItem[], + IsEventMessage: isEventMessage, + IsCommandMessage: !isEventMessage, + }; + }); + + const timeoutMessages = outgoingMessages + .filter((msg) => msg.HasTimeout) + .map( + (msg) => + ({ + ...msg, + TimeoutFriendly: `${msg.TimeoutSeconds}s`, //TODO: Update with logic from ServiceInsight + }) as SagaTimeoutMessage + ); + + const nonTimeoutMessages = outgoingMessages.filter((msg) => !msg.HasTimeout) as SagaMessage[]; + + const hasTimeout = timeoutMessages.length > 0; + + return { + StartTime: startTime, + FinishTime: finishTime, + FormattedStartTime: `${startTime.toLocaleDateString()} ${startTime.toLocaleTimeString()}`, + Status: update.status, + StatusDisplay: update.status === "new" ? "Saga Initiated" : "Saga Updated", + InitiatingMessageType: typeToName(update.initiating_message?.message_type || "Unknown Message") || "", + FormattedInitiatingMessageTimestamp: `${initiatingMessageTimestamp.toLocaleDateString()} ${initiatingMessageTimestamp.toLocaleTimeString()}`, + HasTimeout: hasTimeout, + IsFirstNode: update.status === "new", + TimeoutMessages: timeoutMessages, + NonTimeoutMessages: nonTimeoutMessages, + HasNonTimeoutMessages: nonTimeoutMessages.length > 0, + HasTimeoutMessages: timeoutMessages.length > 0, + }; + }) + .sort((a, b) => a.StartTime.getTime() - b.StartTime.getTime()) + .sort((a, b) => a.FinishTime.getTime() - b.FinishTime.getTime()); +} 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/MessageStore.ts b/src/Frontend/src/stores/MessageStore.ts index 20dcf75e7..b36c98ff4 100644 --- a/src/Frontend/src/stores/MessageStore.ts +++ b/src/Frontend/src/stores/MessageStore.ts @@ -58,12 +58,17 @@ interface Model { show_retry_confirm: boolean; show_edit_retry_modal: boolean; }>; + invoked_saga: Partial<{ + has_saga: boolean; + saga_id: string; + saga_type: string; + }>; } export const useMessageStore = defineStore("MessageStore", () => { const headers = ref>({ data: [] }); const body = ref>({ data: {} }); - const state = reactive>({ data: { failure_metadata: {}, failure_status: {}, dialog_status: {} } }); + const state = reactive>({ data: { failure_metadata: {}, failure_status: {}, dialog_status: {}, invoked_saga: {} } }); let bodyLoadedId = ""; let conversationLoadedId = ""; const conversationData = ref>({ data: [] }); @@ -74,7 +79,7 @@ export const useMessageStore = defineStore("MessageStore", () => { configStore.loadConfig(); function reset() { - state.data = { failure_metadata: {}, failure_status: {}, dialog_status: {} }; + state.data = { failure_metadata: {}, failure_status: {}, dialog_status: {}, invoked_saga: {} }; headers.value.data = []; body.value.data = { value: "", content_type: "" }; bodyLoadedId = ""; @@ -141,7 +146,7 @@ export const useMessageStore = defineStore("MessageStore", () => { state.not_found = headers.value.not_found = true; return; } - + const invokedSaga = message?.invoked_sagas?.[0]; state.data.message_id = message.message_id; state.data.conversation_id = message.conversation_id; state.data.body_url = message.body_url; @@ -150,7 +155,11 @@ export const useMessageStore = defineStore("MessageStore", () => { state.data.receiving_endpoint = message.receiving_endpoint; state.data.status = message.status; state.data.processed_at = message.processed_at; - + if (invokedSaga) { + state.data.invoked_saga.has_saga = true; + state.data.invoked_saga.saga_id = invokedSaga.saga_id; + state.data.invoked_saga.saga_type = invokedSaga.saga_type; + } headers.value.data = message.headers; } catch { state.failed_to_load = headers.value.failed_to_load = true; 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 };