diff --git a/src/Frontend/src/components/EventLogItem.vue b/src/Frontend/src/components/EventLogItem.vue index 9c84449f2..574b879bd 100644 --- a/src/Frontend/src/components/EventLogItem.vue +++ b/src/Frontend/src/components/EventLogItem.vue @@ -1,5 +1,5 @@ + + diff --git a/src/Frontend/src/components/TabsLayout.vue b/src/Frontend/src/components/TabsLayout.vue new file mode 100644 index 000000000..ee2059997 --- /dev/null +++ b/src/Frontend/src/components/TabsLayout.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/src/Frontend/src/components/audit/AuditList.vue b/src/Frontend/src/components/audit/AuditList.vue index f0aaf3d1f..7480676df 100644 --- a/src/Frontend/src/components/audit/AuditList.vue +++ b/src/Frontend/src/components/audit/AuditList.vue @@ -2,7 +2,6 @@ import routeLinks from "@/router/routeLinks"; import { ColumnNames, useAuditStore } from "@/stores/AuditStore"; import { storeToRefs } from "pinia"; -import { useRoute } from "vue-router"; import SortableColumn from "../SortableColumn.vue"; import { MessageStatus } from "@/resources/Message"; import moment from "moment"; @@ -11,7 +10,6 @@ import RefreshConfig from "../RefreshConfig.vue"; import ItemsPerPage from "../ItemsPerPage.vue"; import PaginationStrip from "../PaginationStrip.vue"; -const route = useRoute(); const store = useAuditStore(); const { messages, sortByInstances, itemsPerPage, selectedPage, totalCount } = storeToRefs(store); @@ -101,7 +99,10 @@ function formatDotNetTimespan(timespan: string) { - + + {{ message.message_id }} + + {{ message.message_id }} diff --git a/src/Frontend/src/components/failedmessages/EditRetryDialog.vue b/src/Frontend/src/components/failedmessages/EditRetryDialog.vue index 90d14ce83..c9d202d38 100644 --- a/src/Frontend/src/components/failedmessages/EditRetryDialog.vue +++ b/src/Frontend/src/components/failedmessages/EditRetryDialog.vue @@ -1,6 +1,6 @@ + + + + diff --git a/src/Frontend/src/components/failedmessages/MessageList.vue b/src/Frontend/src/components/failedmessages/MessageList.vue index 932e99274..d01bc4a50 100644 --- a/src/Frontend/src/components/failedmessages/MessageList.vue +++ b/src/Frontend/src/components/failedmessages/MessageList.vue @@ -1,5 +1,5 @@ + + + + diff --git a/src/Frontend/src/components/messages2/DeleteMessageButton.vue b/src/Frontend/src/components/messages2/DeleteMessageButton.vue new file mode 100644 index 000000000..42f527770 --- /dev/null +++ b/src/Frontend/src/components/messages2/DeleteMessageButton.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/Frontend/src/components/messages2/EditAndRetryButton.vue b/src/Frontend/src/components/messages2/EditAndRetryButton.vue new file mode 100644 index 000000000..7a658bc84 --- /dev/null +++ b/src/Frontend/src/components/messages2/EditAndRetryButton.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/Frontend/src/components/messages2/ExportMessageButton.vue b/src/Frontend/src/components/messages2/ExportMessageButton.vue new file mode 100644 index 000000000..4cad84a63 --- /dev/null +++ b/src/Frontend/src/components/messages2/ExportMessageButton.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/src/Frontend/src/components/messages2/FlowDiagram.vue b/src/Frontend/src/components/messages2/FlowDiagram.vue new file mode 100644 index 000000000..93b188bd7 --- /dev/null +++ b/src/Frontend/src/components/messages2/FlowDiagram.vue @@ -0,0 +1,419 @@ + + + + + + + diff --git a/src/Frontend/src/components/messages2/HeadersView.vue b/src/Frontend/src/components/messages2/HeadersView.vue new file mode 100644 index 000000000..fe655a054 --- /dev/null +++ b/src/Frontend/src/components/messages2/HeadersView.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/Frontend/src/components/messages2/MessageView.vue b/src/Frontend/src/components/messages2/MessageView.vue new file mode 100644 index 000000000..b21613616 --- /dev/null +++ b/src/Frontend/src/components/messages2/MessageView.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/src/Frontend/src/components/messages2/MetadataLabel.vue b/src/Frontend/src/components/messages2/MetadataLabel.vue new file mode 100644 index 000000000..353106815 --- /dev/null +++ b/src/Frontend/src/components/messages2/MetadataLabel.vue @@ -0,0 +1,6 @@ + + diff --git a/src/Frontend/src/components/messages2/RestoreMessageButton.vue b/src/Frontend/src/components/messages2/RestoreMessageButton.vue new file mode 100644 index 000000000..dbad6f243 --- /dev/null +++ b/src/Frontend/src/components/messages2/RestoreMessageButton.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/Frontend/src/components/messages2/RetryMessageButton.vue b/src/Frontend/src/components/messages2/RetryMessageButton.vue new file mode 100644 index 000000000..b95d60f32 --- /dev/null +++ b/src/Frontend/src/components/messages2/RetryMessageButton.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/Frontend/src/components/messages2/StacktraceView.vue b/src/Frontend/src/components/messages2/StacktraceView.vue new file mode 100644 index 000000000..8b04bcae2 --- /dev/null +++ b/src/Frontend/src/components/messages2/StacktraceView.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/Frontend/src/composables/toast.ts b/src/Frontend/src/composables/toast.ts index bd54653ac..847afdcd0 100644 --- a/src/Frontend/src/composables/toast.ts +++ b/src/Frontend/src/composables/toast.ts @@ -21,3 +21,8 @@ export function useShowToast(type: TYPE, title: string, message: string, doNotUs ...options, }); } + +export const showToastAfterOperation = async (operation: () => Promise, toastType: TYPE, title: string, message: string) => { + await operation(); + useShowToast(toastType, title, message); +}; diff --git a/src/Frontend/src/composables/useEditAndRetry.ts b/src/Frontend/src/composables/useEditAndRetry.ts new file mode 100644 index 000000000..28cf9b138 --- /dev/null +++ b/src/Frontend/src/composables/useEditAndRetry.ts @@ -0,0 +1,12 @@ +import { useTypedFetchFromServiceControl } from "@/composables/serviceServiceControlUrls"; +import { EditAndRetryConfig } from "@/resources/Configuration"; +import { ref } from "vue"; + +export const editRetryConfig = ref({ enabled: false, locked_headers: [], sensitive_headers: [] }); + +async function populate() { + const [, data] = await useTypedFetchFromServiceControl("edit/config"); + + editRetryConfig.value = data; +} +populate(); diff --git a/src/Frontend/src/resources/Message.ts b/src/Frontend/src/resources/Message.ts index c2d38b17e..7bc049462 100644 --- a/src/Frontend/src/resources/Message.ts +++ b/src/Frontend/src/resources/Message.ts @@ -23,12 +23,6 @@ export default interface Message { invoked_sagas?: SagaInfo[]; originates_from_saga?: SagaInfo; } -export interface ExtendedMessage extends Message { - notFound: boolean; - error: boolean; - headersNotFound: boolean; - messageBodyNotFound: boolean; -} export enum MessageStatus { Failed = "failed", diff --git a/src/Frontend/src/router/config.ts b/src/Frontend/src/router/config.ts index 744f9c3df..6ba66e7e7 100644 --- a/src/Frontend/src/router/config.ts +++ b/src/Frontend/src/router/config.ts @@ -102,14 +102,19 @@ const config: RouteItem[] = [ { path: routeLinks.failedMessage.message.template, title: "Message", - redirect: routeLinks.messages.message.template, + redirect: routeLinks.messages.failedMessage.template, }, ], }, { - path: routeLinks.messages.message.template, + path: routeLinks.messages.failedMessage.template, title: "Message", - component: () => import("@/components/messages/MessageView.vue"), + component: () => import(window.defaultConfig.showAllMessages ? "@/components/messages2/MessageView.vue" : "@/components/messages/MessageView.vue"), + }, + { + path: routeLinks.messages.successMessage.template, + title: "Message", + component: () => import(window.defaultConfig.showAllMessages ? "@/components/messages2/MessageView.vue" : "@/components/messages/MessageView.vue"), }, { path: routeLinks.monitoring.root, diff --git a/src/Frontend/src/router/routeLinks.ts b/src/Frontend/src/router/routeLinks.ts index a98c14088..9cb63ed9a 100644 --- a/src/Frontend/src/router/routeLinks.ts +++ b/src/Frontend/src/router/routeLinks.ts @@ -33,7 +33,8 @@ const failedMessagesLinks = (root: string) => { const messagesLinks = (root: string) => { return { root, - message: { link: (id: string) => `${root}/${id}`, template: "/messages/:id" }, + failedMessage: { link: (id: string) => `${root}/${id}`, template: "/messages/:id" }, + successMessage: { link: (messageId: string, id: string) => `${root}/${messageId}/${id}`, template: "/messages/:messageId/:id" }, }; }; diff --git a/src/Frontend/src/stores/MessageStore.ts b/src/Frontend/src/stores/MessageStore.ts new file mode 100644 index 000000000..a3cfa5326 --- /dev/null +++ b/src/Frontend/src/stores/MessageStore.ts @@ -0,0 +1,313 @@ +import { acceptHMRUpdate, defineStore } from "pinia"; +import { reactive, ref } from "vue"; +import Header from "@/resources/Header"; +import type EndpointDetails from "@/resources/EndpointDetails"; +import FailedMessage, { ExceptionDetails, FailedMessageStatus } from "@/resources/FailedMessage"; +import { editRetryConfig } from "@/composables/useEditAndRetry"; +import { useFetchFromServiceControl, useTypedFetchFromServiceControl } from "@/composables/serviceServiceControlUrls"; +import Message, { MessageStatus } from "@/resources/Message"; +import moment from "moment/moment"; +import { useConfiguration } from "@/composables/configuration"; +import { parse, stringify } from "lossless-json"; +import xmlFormat from "xml-formatter"; +import { useArchiveMessage, useRetryMessages, useUnarchiveMessage } from "@/composables/serviceFailedMessage"; + +interface DataContainer { + loading?: boolean; + failed_to_load?: boolean; + not_found?: boolean; + data: T; +} + +interface Model { + id?: string; + message_id?: string; + conversation_id?: string; + message_type?: string; + sending_endpoint?: EndpointDetails; + receiving_endpoint?: EndpointDetails; + body_url?: string; + status?: MessageStatus; + processed_at?: string; + failure_status: Partial<{ + retried: boolean; + archiving: boolean; + restoring: boolean; + archived: boolean; + resolved: boolean; + delete_soon: boolean; + retry_in_progress: boolean; + delete_in_progress: boolean; + restore_in_progress: boolean; + submitted_for_retrial: boolean; + }>; + failure_metadata: Partial<{ + exception: ExceptionDetails; + number_of_processing_attempts: number; + status: FailedMessageStatus; + time_of_failure: string; + last_modified: string; + edited: boolean; + edit_of: string; + deleted_in: string; + redirect: boolean; + }>; + dialog_status: Partial<{ + show_delete_confirm: boolean; + show_restore_confirm: boolean; + show_retry_confirm: boolean; + show_edit_retry_modal: boolean; + }>; +} + +export const useMessageStore = defineStore("MessageStore", () => { + const headers = ref>({ data: [] }); + const body = ref>({ data: {} }); + const state = reactive>({ data: { failure_metadata: {}, failure_status: {}, dialog_status: {} } }); + let bodyLoadedId = ""; + let conversationLoadedId = ""; + const conversationData = ref>({ data: [] }); + + function reset() { + state.data = { failure_metadata: {}, failure_status: {}, dialog_status: {} }; + headers.value.data = []; + body.value.data = { value: "", content_type: "" }; + bodyLoadedId = ""; + conversationLoadedId = ""; + conversationData.value.data = []; + } + + async function loadFailedMessage(id: string) { + state.loading = true; + state.failed_to_load = false; + state.not_found = false; + + try { + const response = await useFetchFromServiceControl(`errors/last/${id}`); + if (response.status === 404) { + state.not_found = true; + return; + } else if (!response.ok) { + state.failed_to_load = true; + return; + } + + const message = (await response.json()) as FailedMessage; + state.data.message_id = message.message_id; + state.data.message_type = message.message_type; + state.data.sending_endpoint = message.sending_endpoint; + state.data.receiving_endpoint = message.receiving_endpoint; + state.data.failure_status.archived = message.status === FailedMessageStatus.Archived; + state.data.failure_status.resolved = message.status === FailedMessageStatus.Resolved; + state.data.failure_status.retried = message.status === FailedMessageStatus.RetryIssued; + state.data.failure_metadata.last_modified = message.last_modified; + state.data.failure_metadata.exception = message.exception; + state.data.failure_metadata.time_of_failure = message.time_of_failure; + state.data.failure_metadata.edited = message.edited; + state.data.failure_metadata.edit_of = message.edit_of; + state.data.failure_metadata.number_of_processing_attempts = message.number_of_processing_attempts; + state.data.failure_metadata.status = message.status; + + await loadMessage(state.data.message_id, id); + } catch { + state.failed_to_load = true; + return; + } finally { + state.loading = false; + } + + const countdown = moment(state.data.failure_metadata.last_modified).add(error_retention_period, "hours"); + state.data.failure_status.delete_soon = countdown < moment(); + state.data.failure_metadata.deleted_in = countdown.format(); + } + + async function loadMessage(messageId: string, id: string) { + state.data.id = id; + state.loading = headers.value.loading = true; + state.failed_to_load = headers.value.failed_to_load = false; + state.not_found = headers.value.not_found = false; + + try { + const [, data] = await useTypedFetchFromServiceControl(`messages/search/${messageId}`); + + const message = data.find((value) => value.id === id); + + if (!message) { + state.not_found = headers.value.not_found = true; + return; + } + + state.data.message_id = message.message_id; + state.data.conversation_id = message.conversation_id; + state.data.body_url = message.body_url; + state.data.message_type = message.message_type; + state.data.sending_endpoint = message.sending_endpoint; + state.data.receiving_endpoint = message.receiving_endpoint; + state.data.status = message.status; + state.data.processed_at = message.processed_at; + + headers.value.data = message.headers; + } catch { + state.failed_to_load = headers.value.failed_to_load = true; + } finally { + state.loading = headers.value.loading = false; + } + } + + async function loadConversation(conversationId: string) { + if (conversationId === conversationLoadedId) { + return; + } + + conversationLoadedId = conversationId; + conversationData.value.loading = true; + try { + const [, data] = await useTypedFetchFromServiceControl(`conversations/${conversationId}`); + + conversationData.value.data = data; + } catch { + conversationData.value.failed_to_load = true; + } finally { + conversationData.value.loading = false; + } + } + + async function downloadBody() { + if (!state.data.body_url) { + return; + } + if (state.data.id === bodyLoadedId) { + return; + } + + bodyLoadedId = state.data.id ?? ""; + body.value.loading = true; + body.value.failed_to_load = false; + + try { + const response = await useFetchFromServiceControl(state.data.body_url.substring(1)); + if (response.status === 404) { + body.value.not_found = true; + + return; + } + + const contentType = response.headers.get("content-type"); + body.value.data.content_type = contentType ?? "text/plain"; + body.value.data.value = await response.text(); + + if (contentType === "application/json") { + body.value.data.value = stringify(parse(body.value.data.value), null, 2) ?? body.value.data.value; + } + if (contentType === "text/xml") { + body.value.data.value = xmlFormat(body.value.data.value, { indentation: " ", collapseContent: true }); + } + } catch { + body.value.failed_to_load = true; + } finally { + body.value.loading = false; + } + } + + async function archiveMessage() { + if (state.data.id) { + await useArchiveMessage([state.data.id]); + state.data.failure_status.archiving = true; + } + } + + async function restoreMessage() { + if (state.data.id) { + await useUnarchiveMessage([state.data.id]); + state.data.failure_status.restoring = true; + } + } + + async function retryMessage() { + if (state.data.id) { + await useRetryMessages([state.data.id]); + state.data.failure_status.retry_in_progress = true; + } + } + + async function pollForNextUpdate(status: FailedMessageStatus) { + if (!state.data.id) { + return; + } + + let maxRetries = 60; // We try for 60 seconds + + do { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 1000)); + // eslint-disable-next-line no-await-in-loop + const [, data] = await useTypedFetchFromServiceControl(`errors/last/${state.data.id}`); + if (status === data.status) { + break; + } + } while (maxRetries-- > 0); + + if (maxRetries === 0) { + // It never changed so no need to refresh UI + return; + } + + const id = state.data.id; + reset(); + await loadFailedMessage(id); + } + + async function exportMessage() { + if (state.failed_to_load || state.not_found) { + return ""; + } + + let exportString = ""; + if (state.data.failure_metadata.exception?.stack_trace !== undefined) { + exportString += "STACKTRACE\n"; + exportString += state.data.failure_metadata.exception.stack_trace; + exportString += "\n\n"; + } + + exportString += "HEADERS"; + for (let i = 0; i < headers.value.data.length; i++) { + exportString += `\n${headers.value.data[i].key}: ${headers.value.data[i].value}`; + } + + await downloadBody(); + + if (!(body.value.not_found || body.value.failed_to_load)) { + exportString += "\n\nMESSAGE BODY\n"; + exportString += body.value.data.value; + } + + return exportString; + } + + const configuration = useConfiguration(); + const error_retention_period = moment.duration(configuration.value?.data_retention.error_retention_period).asHours(); + + return { + headers, + body, + state, + edit_and_retry_config: editRetryConfig, + reset, + loadMessage, + loadFailedMessage, + loadConversation, + downloadBody, + exportMessage, + archiveMessage, + restoreMessage, + retryMessage, + conversationData, + pollForNextUpdate, + }; +}); + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useMessageStore, import.meta.hot)); +} + +export type MessageStore = ReturnType;