diff --git a/src/Frontend/package-lock.json b/src/Frontend/package-lock.json
index 302d45f2e..eab07dd81 100644
--- a/src/Frontend/package-lock.json
+++ b/src/Frontend/package-lock.json
@@ -126,7 +126,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -616,7 +615,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
"integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -629,7 +627,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz",
"integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
@@ -666,7 +663,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
@@ -690,7 +686,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
"integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
@@ -702,7 +697,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
"integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
@@ -714,7 +708,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
@@ -724,7 +717,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz",
"integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -820,7 +812,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -844,7 +835,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -1529,7 +1519,6 @@
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
@@ -2043,7 +2032,6 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
@@ -2349,7 +2337,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -2616,7 +2603,6 @@
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/types": "8.47.0",
@@ -3033,7 +3019,6 @@
"resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.0.tgz",
"integrity": "sha512-keW9HGaEZEe4SKYtrzp5E+qSGJ5/z+9i2yRDtCr3o72IUnS0Ns1qQNsIbGGz0ygpKzg6LdtbVLWeYAvl3dzLQA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vueuse/core": "^10.5.0",
"d3-drag": "^3.0.0",
@@ -3186,7 +3171,6 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz",
"integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.25",
@@ -3507,7 +3491,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3770,7 +3753,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -4008,7 +3990,6 @@
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
@@ -4235,7 +4216,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -4720,7 +4700,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4781,7 +4760,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -6839,7 +6817,6 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
@@ -6924,7 +6901,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -7607,8 +7583,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/superjson": {
"version": "2.2.2",
@@ -7733,7 +7708,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -7883,7 +7857,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8030,7 +8003,6 @@
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -8346,7 +8318,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -8360,7 +8331,6 @@
"integrity": "sha512-pmW4GCKQ8t5Ko1jYjC3SqOr7TUKN7uHOHB/XGsAIb69eYu6d1ionGSsb5H9chmPf+WeXt0VE7jTXsB1IvWoNbw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/expect": "4.0.12",
"@vitest/mocker": "4.0.12",
@@ -8462,7 +8432,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",
@@ -8610,7 +8579,6 @@
"integrity": "sha512-L/G9IUjOWhBU0yun89rv8fKqmKC+T0HfhrFjlIml71WpfBv9eb4E9Bev8FMbyueBIU9vxQqbd+oOsVcDa5amGw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@volar/typescript": "2.4.23",
"@vue/language-core": "3.1.5"
diff --git a/src/Frontend/src/components/failedmessages/DeletedMessages.vue b/src/Frontend/src/components/failedmessages/DeletedMessages.vue
index 1be80920a..cb1af3217 100644
--- a/src/Frontend/src/components/failedmessages/DeletedMessages.vue
+++ b/src/Frontend/src/components/failedmessages/DeletedMessages.vue
@@ -1,134 +1,31 @@
@@ -241,7 +114,7 @@ onMounted(() => {
@@ -250,11 +123,12 @@ onMounted(() => {
-import { onMounted, onUnmounted, ref, useTemplateRef, watch } from "vue";
+import { computed, onBeforeMount, ref, useTemplateRef, watch } from "vue";
import { useShowToast } from "../../composables/toast";
import { downloadFileFromString } from "../../composables/fileDownloadCreator";
-import { onBeforeRouteLeave, useRoute } from "vue-router";
+import { onBeforeRouteLeave } from "vue-router";
import createMessageGroupClient from "./messageGroupClient";
import LicenseNotExpired from "../../components/LicenseNotExpired.vue";
import OrderBy from "@/components/OrderBy.vue";
@@ -10,33 +10,32 @@ import ServiceControlAvailable from "../ServiceControlAvailable.vue";
import MessageList, { IMessageList } from "./MessageList.vue";
import ConfirmDialog from "../ConfirmDialog.vue";
import PaginationStrip from "../../components/PaginationStrip.vue";
-import { ExtendedFailedMessage, FailedMessageStatus } from "@/resources/FailedMessage";
-import SortOptions, { SortDirection } from "@/resources/SortOptions";
+import { FailedMessageStatus } from "@/resources/FailedMessage";
+import SortOptions from "@/resources/SortOptions";
import { TYPE } from "vue-toastification";
import GroupOperation from "@/resources/GroupOperation";
import { faArrowDownAZ, faArrowDownZA, faArrowDownShortWide, faArrowDownWideShort, faArrowRotateRight, faTrash, faDownload } from "@fortawesome/free-solid-svg-icons";
import ActionButton from "@/components/ActionButton.vue";
-import { useServiceControlStore } from "@/stores/ServiceControlStore";
import { useMessageStore } from "@/stores/MessageStore";
+import { useRecoverabilityStore } from "@/stores/RecoverabilityStore";
+import { useStoreAutoRefresh } from "@/composables/useAutoRefresh";
+import { storeToRefs } from "pinia";
+import LoadingSpinner from "../LoadingSpinner.vue";
+
+const POLLING_INTERVAL_NORMAL = 5000;
+const POLLING_INTERVAL_FAST = 1000;
-const serviceControlStore = useServiceControlStore();
const messageStore = useMessageStore();
const messageGroupClient = createMessageGroupClient();
+const loading = ref(false);
+const { autoRefresh, isRefreshing, updateInterval } = useStoreAutoRefresh("messagesStore", useRecoverabilityStore, POLLING_INTERVAL_NORMAL);
+const { store } = autoRefresh();
+const { messages, groupId, groupName, totalCount, pageNumber } = storeToRefs(store);
-let pollingFaster = false;
-let refreshInterval: number | undefined;
-let sortMethod: SortOptions | undefined;
-const perPage = 50;
-const route = useRoute();
-const groupId = ref(route.params.groupId as string);
-const groupName = ref("");
-const pageNumber = ref(1);
-const totalCount = ref(0);
const showDelete = ref(false);
const showConfirmRetryAll = ref(false);
const showConfirmDeleteAll = ref(false);
const messageList = useTemplateRef("messageList");
-const messages = ref([]);
const sortOptions: SortOptions[] = [
{
description: "Time of failure",
@@ -50,73 +49,15 @@ const sortOptions: SortOptions[] = [
},
];
-watch(pageNumber, () => loadMessages());
-
-function sortGroups(sort: SortOptions) {
- sortMethod = sort;
- loadMessages();
-}
-
-function loadMessages() {
- loadPagedMessages(groupId.value, pageNumber.value, sortMethod && sortMethod.description.replaceAll(" ", "_").toLowerCase(), sortMethod?.dir);
-}
-
-async function loadGroupDetails(groupId: string) {
- const response = await serviceControlStore.fetchFromServiceControl(`recoverability/groups/id/${groupId}`);
- const data = await response.json();
- groupName.value = data.title;
-}
-
-function loadPagedMessages(groupId: string, page: number, sortBy?: string, direction?: SortDirection) {
- sortBy ??= "time_of_failure";
- direction ??= SortDirection.Descending;
-
- let loadGroupDetailsPromise;
- if (groupId && !groupName.value) {
- loadGroupDetailsPromise = loadGroupDetails(groupId);
- }
-
- async function loadMessages() {
- try {
- const [response, data] = await serviceControlStore.fetchTypedFromServiceControl(
- `${groupId ? `recoverability/groups/${groupId}/` : ""}errors?status=${FailedMessageStatus.Unresolved}&page=${page}&per_page=${perPage}&sort=${sortBy}&direction=${direction}`
- );
- totalCount.value = parseInt(response.headers.get("Total-Count") ?? "");
- if (messages.value.length && data.length) {
- // merge the previously selected messages into the new list so we can replace them
- messages.value.forEach((previousMessage) => {
- const receivedMessage = data.find((m) => m.id === previousMessage.id);
- if (receivedMessage) {
- if (previousMessage.last_modified === receivedMessage.last_modified) {
- receivedMessage.retryInProgress = previousMessage.retryInProgress;
- receivedMessage.deleteInProgress = previousMessage.deleteInProgress;
- }
-
- receivedMessage.selected = previousMessage.selected;
- }
- });
- }
- messages.value = data;
- } catch (err) {
- console.log(err);
- const result = {
- message: "error",
- };
- return result;
- }
- }
-
- const loadMessagesPromise = loadMessages();
-
- if (loadGroupDetailsPromise) {
- return Promise.all([loadGroupDetailsPromise, loadMessagesPromise]);
- }
-
- return loadMessagesPromise;
+async function sortGroups(sort: SortOptions) {
+ loading.value = true;
+ await store.setSort(sort.description.replaceAll(" ", "_").toLowerCase(), sort.dir);
+ loading.value = false;
}
async function retryRequested(id: string) {
- changeRefreshInterval(1000);
+ // We're starting a retry, poll more frequently
+ updateInterval(POLLING_INTERVAL_FAST);
useShowToast(TYPE.INFO, "Info", "Message retry requested...");
await messageStore.retryMessages([id]);
const message = messages.value.find((m) => m.id === id);
@@ -127,7 +68,8 @@ async function retryRequested(id: string) {
}
async function retrySelected() {
- changeRefreshInterval(1000);
+ // We're starting a retry, poll more frequently
+ updateInterval(POLLING_INTERVAL_FAST);
const selectedMessages = messageList.value?.getSelectedMessages() ?? [];
useShowToast(TYPE.INFO, "Info", "Retrying " + selectedMessages.length + " messages...");
await messageStore.retryMessages(selectedMessages.map((m) => m.id));
@@ -210,14 +152,12 @@ function isAnythingSelected() {
}
async function deleteSelectedMessages() {
- changeRefreshInterval(1000);
+ // We're starting a delete, poll more frequently
+ updateInterval(POLLING_INTERVAL_FAST);
const selectedMessages = messageList.value?.getSelectedMessages() ?? [];
useShowToast(TYPE.INFO, "Info", "Deleting " + selectedMessages.length + " messages...");
- await serviceControlStore.patchToServiceControl(
- "errors/archive",
- selectedMessages.map((m) => m.id)
- );
+ await store.deleteById(selectedMessages.map((m) => m.id));
messageList.value?.deselectAll();
selectedMessages.forEach((m) => (m.deleteInProgress = true));
}
@@ -234,47 +174,29 @@ async function deleteGroup() {
messages.value.forEach((m) => (m.deleteInProgress = true));
}
-function isRetryOrDeleteOperationInProgress() {
- return messages.value.some((message) => {
- return message.retryInProgress || message.deleteInProgress;
- });
-}
-
-function changeRefreshInterval(milliseconds: number) {
- if (refreshInterval != null) {
- window.clearInterval(refreshInterval);
- }
-
- refreshInterval = window.setInterval(() => {
- // If we're currently polling at 5 seconds and there is a retry or delete in progress, then change the polling interval to poll every 1 second
- if (!pollingFaster && isRetryOrDeleteOperationInProgress()) {
- changeRefreshInterval(1000);
- pollingFaster = true;
- } else if (pollingFaster && !isRetryOrDeleteOperationInProgress()) {
- // if we're currently polling every 1 second but all retries or deletes are done, change polling frequency back to every 5 seconds
- changeRefreshInterval(5000);
- pollingFaster = false;
- }
-
- loadMessages();
- }, milliseconds);
-}
-
onBeforeRouteLeave(() => {
groupId.value = "";
groupName.value = "";
});
-onUnmounted(() => {
- if (refreshInterval != null) {
- window.clearInterval(refreshInterval);
+const isRetryOrDeleteOperationInProgress = computed(() => messages.value.some((message) => message.retryInProgress || message.deleteInProgress));
+watch(isRetryOrDeleteOperationInProgress, (retryOrDeleteOperationInProgress) => {
+ // If there is a retry or delete in progress, then change the polling interval to poll every 1 second
+ if (retryOrDeleteOperationInProgress) {
+ updateInterval(POLLING_INTERVAL_FAST);
+ } else {
+ // if all retries or deletes are done, change polling frequency back to every 5 seconds
+ updateInterval(POLLING_INTERVAL_NORMAL);
}
});
-onMounted(() => {
- loadMessages();
-
- changeRefreshInterval(5000);
+onBeforeMount(async () => {
+ loading.value = true;
+ //set status before mount to ensure no other controls/processes can cause extra refreshes during mount
+ await store.setMessageStatus(FailedMessageStatus.Unresolved);
+});
+watch(isRefreshing, () => {
+ if (!isRefreshing.value && loading.value) loading.value = false;
});
@@ -308,11 +230,12 @@ onMounted(() => {
-import { onMounted, onUnmounted, ref, useTemplateRef, watch } from "vue";
+import { onBeforeMount, ref, useTemplateRef, watch } from "vue";
import { useShowToast } from "../../composables/toast";
-import { useCookies } from "vue3-cookies";
import OrderBy from "@/components/OrderBy.vue";
import LicenseNotExpired from "../../components/LicenseNotExpired.vue";
import ServiceControlAvailable from "../ServiceControlAvailable.vue";
import MessageList, { IMessageList } from "./MessageList.vue";
import ConfirmDialog from "../ConfirmDialog.vue";
import PaginationStrip from "../../components/PaginationStrip.vue";
-import { ExtendedFailedMessage, FailedMessageStatus } from "@/resources/FailedMessage";
-import SortOptions, { SortDirection } from "@/resources/SortOptions";
-import QueueAddress from "@/resources/QueueAddress";
+import { FailedMessageStatus } from "@/resources/FailedMessage";
+import SortOptions from "@/resources/SortOptions";
import { TYPE } from "vue-toastification";
import GroupOperation from "@/resources/GroupOperation";
import { faArrowDownAZ, faArrowDownZA, faArrowDownShortWide, faArrowDownWideShort, faInfoCircle, faExternalLink, faFilter, faTimes, faArrowRightRotate } from "@fortawesome/free-solid-svg-icons";
import FAIcon from "@/components/FAIcon.vue";
import ActionButton from "@/components/ActionButton.vue";
import { faCheckSquare } from "@fortawesome/free-regular-svg-icons";
-import { useServiceControlStore } from "@/stores/ServiceControlStore";
import { useConfigurationStore } from "@/stores/ConfigurationStore";
import { storeToRefs } from "pinia";
+import { useStoreAutoRefresh } from "@/composables/useAutoRefresh";
+import { RetryPeriodOption, useRecoverabilityStore } from "@/stores/RecoverabilityStore";
-const serviceControlStore = useServiceControlStore();
+const loading = ref(false);
+const { autoRefresh, isRefreshing } = useStoreAutoRefresh("messagesStore", useRecoverabilityStore, 5000);
+const { store } = autoRefresh();
+const { messages, totalCount, pageNumber, selectedPeriod, selectedQueue, endpoints } = storeToRefs(store);
const configurationStore = useConfigurationStore();
const { isMassTransitConnected } = storeToRefs(configurationStore);
-let refreshInterval: number | undefined;
-let sortMethod: SortOptions | undefined;
-const perPage = 50;
-const cookies = useCookies().cookies;
-const selectedPeriod = ref("All Pending Retries");
-const endpoints = ref([]);
const messageList = useTemplateRef("messageList");
-const messages = ref([]);
-const selectedQueue = ref("empty");
const showConfirmRetry = ref(false);
const showConfirmResolve = ref(false);
const showConfirmResolveAll = ref(false);
const showCantRetryAll = ref(false);
const showRetryAllConfirm = ref(false);
-const pageNumber = ref(1);
-const totalCount = ref(0);
-const isInitialLoad = ref(true);
const sortOptions: SortOptions[] = [
{
description: "Time of failure",
@@ -59,76 +50,6 @@ const sortOptions: SortOptions[] = [
iconDesc: faArrowDownWideShort,
},
];
-const periodOptions = ["All Pending Retries", "Retried in the last 2 Hours", "Retried in the last 1 Day", "Retried in the last 7 Days"];
-
-watch(pageNumber, () => loadPendingRetryMessages());
-
-async function loadEndpoints() {
- const [, data] = await serviceControlStore.fetchTypedFromServiceControl("errors/queues/addresses");
- endpoints.value = data.map((endpoint) => endpoint.physical_address);
-}
-
-function clearSelectedQueue() {
- selectedQueue.value = "empty";
- loadPendingRetryMessages();
-}
-
-function loadPendingRetryMessages() {
- let startDate = new Date(0);
- const endDate = new Date();
-
- switch (selectedPeriod.value) {
- case "Retried in the last 2 Hours":
- startDate = new Date();
- startDate.setHours(startDate.getHours() - 2);
- break;
-
- case "Retried in the last 1 Day":
- startDate = new Date();
- startDate.setHours(startDate.getHours() - 24);
- break;
-
- case "Retried in the last 7 days":
- startDate = new Date();
- startDate.setHours(startDate.getHours() - 24 * 7);
- break;
- }
-
- return loadPagedPendingRetryMessages(pageNumber.value, selectedQueue.value, startDate, endDate, sortMethod?.description.replaceAll(" ", "_").toLowerCase(), sortMethod?.dir);
-}
-
-async function loadPagedPendingRetryMessages(page: number, searchPhrase: string, startDate: Date, endDate: Date, sortBy?: string, direction?: SortDirection) {
- sortBy ??= "time_of_failure";
- direction ??= SortDirection.Descending;
- if (searchPhrase === "empty") searchPhrase = "";
-
- try {
- const [response, data] = await serviceControlStore.fetchTypedFromServiceControl(
- `errors?status=${FailedMessageStatus.RetryIssued}&page=${page}&per_page=${perPage}&sort=${sortBy}&direction=${direction}&queueaddress=${searchPhrase}&modified=${startDate.toISOString()}...${endDate.toISOString()}`
- );
- totalCount.value = parseInt(response.headers.get("Total-Count") ?? "0");
-
- messages.value.forEach((previousMessage: ExtendedFailedMessage) => {
- const receivedMessage = data.find((m) => m.id === previousMessage.id);
- if (receivedMessage) {
- if (previousMessage.last_modified === receivedMessage.last_modified) {
- receivedMessage.submittedForRetrial = previousMessage.submittedForRetrial;
- receivedMessage.resolved = previousMessage.resolved;
- }
-
- receivedMessage.selected = previousMessage.selected;
- }
- });
-
- messages.value = data;
- } catch (err) {
- console.log(err);
- const result = {
- message: "error",
- };
- return result;
- }
-}
function numberDisplayed() {
return messageList.value?.numberDisplayed();
@@ -150,10 +71,7 @@ async function retrySelectedMessages() {
const selectedMessages = messageList.value?.getSelectedMessages() ?? [];
useShowToast(TYPE.INFO, "Info", "Selected messages were submitted for retry...");
- await serviceControlStore.postToServiceControl(
- "pendingretries/retry",
- selectedMessages.map((m) => m.id)
- );
+ await store.retryById(selectedMessages.map((m) => m.id));
messageList.value?.deselectAll();
selectedMessages.forEach((m) => (m.submittedForRetrial = true));
@@ -163,30 +81,20 @@ async function resolveSelectedMessages() {
const selectedMessages = messageList.value?.getSelectedMessages() ?? [];
useShowToast(TYPE.INFO, "Info", "Selected messages were marked as resolved.");
- await serviceControlStore.patchToServiceControl("pendingretries/resolve", { uniquemessageids: selectedMessages.map((m) => m.id) });
+ await store.resolveById(selectedMessages.map((m) => m.id));
messageList.value?.deselectAll();
selectedMessages.forEach((m) => (m.resolved = true));
}
async function resolveAllMessages() {
useShowToast(TYPE.INFO, "Info", "All filtered messages were marked as resolved.");
- await serviceControlStore.patchToServiceControl("pendingretries/resolve", { from: new Date(0).toISOString(), to: new Date().toISOString() });
+ await store.resolveAll();
messageList.value?.deselectAll();
messageList.value?.resolveAll();
}
async function retryAllMessages() {
- let url = "pendingretries/retry";
- const data: { from: string; to: string; queueaddress?: string } = {
- from: new Date(0).toISOString(),
- to: new Date(0).toISOString(),
- };
- if (selectedQueue.value !== "empty") {
- url = "pendingretries/queues/retry";
- data.queueaddress = selectedQueue.value;
- }
-
- await serviceControlStore.postToServiceControl(url, data);
+ await store.retryAll();
messages.value.forEach((message) => {
message.selected = false;
message.submittedForRetrial = true;
@@ -202,44 +110,25 @@ function retryAllClicked() {
}
}
-function sortGroups(sort: SortOptions) {
- sortMethod = sort;
-
- if (!isInitialLoad.value) {
- loadPendingRetryMessages();
- }
+async function sortGroups(sort: SortOptions) {
+ loading.value = true;
+ await store.setSort(sort.description.replaceAll(" ", "_").toLowerCase(), sort.dir);
+ loading.value = false;
}
-function periodChanged(period: string) {
- selectedPeriod.value = period;
- cookies.set("pending_retries_period", period);
-
- loadPendingRetryMessages();
+async function periodChanged(period: RetryPeriodOption) {
+ loading.value = true;
+ await store.setPeriod(period);
+ loading.value = false;
}
-onUnmounted(() => {
- if (refreshInterval != null) {
- window.clearInterval(refreshInterval);
- }
+onBeforeMount(async () => {
+ loading.value = true;
+ //set status before mount to ensure no other controls/processes can cause extra refreshes during mount
+ await store.setMessageStatus(FailedMessageStatus.RetryIssued);
});
-
-onMounted(() => {
- let cookiePeriod = cookies.get("pending_retries_period");
- if (!cookiePeriod) {
- cookiePeriod = periodOptions[0]; //default All Pending Retries
- }
-
- selectedPeriod.value = cookiePeriod;
-
- loadEndpoints();
-
- loadPendingRetryMessages();
-
- refreshInterval = window.setInterval(() => {
- loadPendingRetryMessages();
- }, 5000);
-
- isInitialLoad.value = false;
+watch(isRefreshing, () => {
+ if (!isRefreshing.value && loading.value) loading.value = false;
});
@@ -263,14 +152,14 @@ onMounted(() => {
@@ -283,7 +172,7 @@ onMounted(() => {
@@ -307,7 +196,7 @@ onMounted(() => {
1) {
- days = parseInt(parts[0], 10);
- timeComponent = parts[1];
- }
-
- const [hours, minutes, seconds] = timeComponent.split(":").map(Number);
- return { days, hours, minutes, seconds };
-}
+import { parseTimeSpan } from "./formatter";
function getFriendly(time: number, text: string): string {
return time > 0 ? `${time}${text}` : "";
}
export function getTimeoutFriendly(delivery_delay: string): string {
- const { days, hours, minutes, seconds } = parseDeliveryDelay(delivery_delay);
+ const { days, hours, minutes, seconds } = parseTimeSpan(delivery_delay);
return `${getFriendly(days, "d")}${getFriendly(hours, "h")}${getFriendly(minutes, "m")}${getFriendly(seconds, "s")}`;
}
diff --git a/src/Frontend/src/composables/formatter.ts b/src/Frontend/src/composables/formatter.ts
index 12a01ef5b..a535ef869 100644
--- a/src/Frontend/src/composables/formatter.ts
+++ b/src/Frontend/src/composables/formatter.ts
@@ -76,3 +76,24 @@ function formatTimeValue(timeValue: number, displayTwoDigits = false) {
const strValue = Math.floor(timeValue);
return `${displayTwoDigits ? ("0" + strValue).slice(-2) : strValue.toLocaleString()}`;
}
+
+export function parseTimeSpan(timeSpan: string) {
+ // Split on period first to handle multi-digit days
+ const parts = timeSpan.split(".");
+ let days = 0;
+ let timeComponent = timeSpan;
+
+ if (parts.length > 1) {
+ days = parseInt(parts[0], 10);
+ timeComponent = parts[1];
+ }
+
+ const [hours, minutes, seconds] = timeComponent.split(":").map(Number);
+ return { days, hours, minutes, seconds };
+}
+
+export function timeSpanToDuration(timeSpan: string | undefined) {
+ if (!timeSpan) return dayjs.duration("PT0S");
+
+ return dayjs.duration(parseTimeSpan(timeSpan));
+}
diff --git a/src/Frontend/src/stores/ConnectionsAndStatsStore.ts b/src/Frontend/src/stores/ConnectionsAndStatsStore.ts
index 48086baf8..cea10048f 100644
--- a/src/Frontend/src/stores/ConnectionsAndStatsStore.ts
+++ b/src/Frontend/src/stores/ConnectionsAndStatsStore.ts
@@ -18,7 +18,7 @@ export const useConnectionsAndStatsStore = defineStore("ConnectionsAndStatsStore
const { count: requiresFullFailureDetailsSubscriberCount, inc, dec } = useCounter(0);
function requiresFullFailureDetails() {
- onMounted(() => inc());
+ onMounted(() => inc()); //NOTE: not forcing a refresh here since we expect the view utilising this store to also setup a refresh on mount
onUnmounted(() => dec());
}
diff --git a/src/Frontend/src/stores/MessageStore.ts b/src/Frontend/src/stores/MessageStore.ts
index 36050ce6d..1c79009fb 100644
--- a/src/Frontend/src/stores/MessageStore.ts
+++ b/src/Frontend/src/stores/MessageStore.ts
@@ -14,6 +14,7 @@ import { EditAndRetryConfig } from "@/resources/Configuration";
import EditRetryResponse from "@/resources/EditRetryResponse";
import { EditedMessage } from "@/resources/EditMessage";
import useEnvironmentAndVersionsAutoRefresh from "@/composables/useEnvironmentAndVersionsAutoRefresh";
+import { timeSpanToDuration } from "@/composables/formatter";
interface Model {
id?: string;
@@ -79,7 +80,7 @@ export const useMessageStore = defineStore("MessageStore", () => {
const areSimpleHeadersSupported = environmentStore.serviceControlIsGreaterThan("5.2.0");
const { configuration } = storeToRefs(configStore);
- const error_retention_period = computed(() => dayjs.duration(configuration.value?.data_retention?.error_retention_period ?? "PT0S").asHours());
+ const error_retention_period = computed(() => timeSpanToDuration(configuration.value?.data_retention?.error_retention_period).asHours());
watch(serviceControlUrl, loadConfig, { immediate: true });
async function loadConfig() {
diff --git a/src/Frontend/src/stores/RecoverabilityStore.ts b/src/Frontend/src/stores/RecoverabilityStore.ts
new file mode 100644
index 000000000..9260865bb
--- /dev/null
+++ b/src/Frontend/src/stores/RecoverabilityStore.ts
@@ -0,0 +1,281 @@
+import { acceptHMRUpdate, defineStore, storeToRefs } from "pinia";
+import { computed, ref, watch, shallowReadonly } from "vue";
+import { useServiceControlStore } from "./ServiceControlStore";
+import { useCookies } from "vue3-cookies";
+import { useRoute } from "vue-router";
+import { ExtendedFailedMessage, FailedMessageStatus } from "@/resources/FailedMessage";
+import { SortDirection } from "@/resources/SortOptions";
+import dayjs from "@/utils/dayjs";
+import { useConfigurationStore } from "./ConfigurationStore";
+import FailureGroup from "@/resources/FailureGroup";
+import QueueAddress from "@/resources/QueueAddress";
+import { timeSpanToDuration } from "@/composables/formatter";
+
+const deletedPeriodOptions = ["All Deleted", "Deleted in the last 2 Hours", "Deleted in the last 1 Day", "Deleted in the last 7 days"] as const;
+const retryPeriodOptions = ["All Pending Retries", "Retried in the last 2 Hours", "Retried in the last 1 Day", "Retried in the last 7 Days"] as const;
+export type DeletedPeriodOption = (typeof deletedPeriodOptions)[number];
+export type RetryPeriodOption = (typeof retryPeriodOptions)[number];
+
+export const useRecoverabilityStore = defineStore("RecoverabilityStore", () => {
+ const route = useRoute();
+ const groupId = ref(route.params.groupId as string);
+ const groupName = ref("");
+ const pageNumber = ref(1);
+ const totalCount = ref(0);
+ const messages = ref([]);
+ let messageStatus: FailedMessageStatus | null = null;
+ const perPage = 50;
+ const sortBy = ref("");
+ const sortDirection = ref(SortDirection.Descending);
+ const startDate = ref(new Date(0));
+ const endDate = ref(new Date());
+ const dateRange = computed(() => `${startDate.value.toISOString()}...${endDate.value.toISOString()}`);
+ const selectedPeriod = ref("Deleted in the last 7 days");
+ const selectedQueue = ref("empty");
+ const endpoints = ref([]);
+
+ const serviceControlStore = useServiceControlStore();
+ const configurationStore = useConfigurationStore();
+ const { configuration } = storeToRefs(configurationStore);
+
+ const cookies = useCookies();
+
+ //keep track of first load after a status change, so that loading of subcomponents that initiate a refresh (e.g. OrderBy) don't cause a double refresh on "page" load
+ let loaded = false;
+
+ watch(pageNumber, () => refresh());
+
+ let controller: AbortController | null;
+ async function refresh() {
+ try {
+ if (!messageStatus) return;
+ if (messageStatus === FailedMessageStatus.Archived || messageStatus === FailedMessageStatus.RetryIssued) {
+ endDate.value = new Date();
+ const newStartDate = new Date();
+
+ switch (selectedPeriod.value) {
+ case "All Deleted":
+ case "All Pending Retries":
+ newStartDate.setHours(newStartDate.getHours() - 24 * 365);
+ break;
+ case "Deleted in the last 2 Hours":
+ case "Retried in the last 2 Hours":
+ newStartDate.setHours(newStartDate.getHours() - 2);
+ break;
+ case "Deleted in the last 1 Day":
+ case "Retried in the last 1 Day":
+ newStartDate.setHours(newStartDate.getHours() - 24);
+ break;
+ case "Deleted in the last 7 days":
+ case "Retried in the last 7 Days":
+ newStartDate.setHours(newStartDate.getHours() - 24 * 7);
+ break;
+ }
+ startDate.value = newStartDate;
+ }
+ const additionalQuery = (() => {
+ switch (messageStatus) {
+ case FailedMessageStatus.Archived:
+ return `&modified=${dateRange.value}`;
+ case FailedMessageStatus.RetryIssued: {
+ const searchPhrase = selectedQueue.value === "empty" ? "" : selectedQueue.value;
+ return `&queueaddress=${searchPhrase}&modified=${dateRange.value}`;
+ }
+ default:
+ return "";
+ }
+ })();
+
+ controller = new AbortController();
+ if (groupId.value && !groupName.value) loadGroupDetails(groupId.value);
+ const [response, data] = await serviceControlStore.fetchTypedFromServiceControl(
+ `${groupId.value ? `recoverability/groups/${groupId.value}/` : ""}errors?status=${messageStatus}&page=${pageNumber.value}&per_page=${perPage}&sort=${sortBy.value}&direction=${sortDirection.value}${additionalQuery}`,
+ controller.signal
+ );
+ controller = null;
+
+ totalCount.value = parseInt(response.headers.get("Total-Count") ?? "0");
+
+ if (messages.value.length && data.length) {
+ // merge the previously selected messages into the new list so we can replace them
+ messages.value.forEach((previousMessage) => {
+ const receivedMessage = data.find((m) => m.id === previousMessage.id);
+ if (receivedMessage) {
+ if (previousMessage.last_modified === receivedMessage.last_modified) {
+ receivedMessage.retryInProgress = previousMessage.retryInProgress;
+ receivedMessage.deleteInProgress = previousMessage.deleteInProgress;
+ receivedMessage.restoreInProgress = previousMessage.restoreInProgress;
+ receivedMessage.submittedForRetrial = previousMessage.submittedForRetrial;
+ receivedMessage.resolved = previousMessage.resolved;
+ }
+
+ receivedMessage.selected = previousMessage.selected;
+ }
+ });
+ }
+
+ messages.value = updateMessages(data);
+ loaded = true;
+ } catch (err) {
+ if (err instanceof Error && err.name === "AbortError") return;
+ console.error(err);
+ }
+ }
+
+ async function loadGroupDetails(groupId: string) {
+ const [, data] = await serviceControlStore.fetchTypedFromServiceControl(`${messageStatus === FailedMessageStatus.Archived ? "archive" : "recoverability"}/groups/id/${groupId}`, controller?.signal);
+ groupName.value = data.title;
+ }
+
+ function updateMessages(messages: ExtendedFailedMessage[]) {
+ switch (messageStatus) {
+ case FailedMessageStatus.Archived:
+ //check deletion time
+ messages.forEach((message) => {
+ message.error_retention_period = timeSpanToDuration(configuration.value?.data_retention.error_retention_period).asHours();
+ const countdown = dayjs(message.last_modified).add(message.error_retention_period, "hours");
+ message.delete_soon = countdown < dayjs();
+ message.deleted_in = countdown.format();
+ });
+ return messages;
+ default:
+ return messages;
+ }
+ }
+
+ async function setMessageStatus(status: FailedMessageStatus) {
+ if (controller) {
+ // need to cancel any existing fetch which otherwise will set messages of the incorrect status
+ controller.abort();
+ }
+ loaded = false;
+ messageStatus = status;
+ messages.value = [];
+ //reset all the paging variables
+ switch (messageStatus) {
+ case FailedMessageStatus.Archived: {
+ sortBy.value = "";
+ let deletedMessagePeriod = cookies.cookies.get("all_deleted_messages_period") as DeletedPeriodOption;
+ if (!deletedMessagePeriod) {
+ deletedMessagePeriod = deletedPeriodOptions[deletedPeriodOptions.length - 1]; //default is last 7 days
+ }
+ selectedPeriod.value = deletedMessagePeriod;
+ break;
+ }
+ case FailedMessageStatus.RetryIssued:
+ {
+ sortBy.value = "time_of_failure";
+ let retryMessagePeriod = cookies.cookies.get("pending_retries_period") as RetryPeriodOption;
+ if (!retryMessagePeriod) {
+ retryMessagePeriod = retryPeriodOptions[0]; //default All Pending Retries
+ }
+ selectedPeriod.value = retryMessagePeriod;
+ const [, data] = await serviceControlStore.fetchTypedFromServiceControl("errors/queues/addresses");
+ endpoints.value = data.map((endpoint) => endpoint.physical_address);
+ }
+ break;
+ default:
+ sortBy.value = "time_of_failure";
+ }
+
+ groupId.value = route.params.groupId as string;
+
+ //Refresh is handled by the autoRefresh setup on the respective views, so doing it here also would cause a double refresh
+ //await refresh();
+ }
+
+ async function setSort(sort: string, direction?: SortDirection) {
+ sortBy.value = sort;
+ sortDirection.value = direction ?? SortDirection.Descending;
+ if (!loaded) return;
+ if (controller) {
+ // need to cancel any existing fetch which otherwise will set messages of the incorrect sort
+ controller.abort();
+ }
+ messages.value = [];
+ await refresh();
+ }
+
+ async function setPeriod(period: DeletedPeriodOption | RetryPeriodOption) {
+ selectedPeriod.value = period;
+ cookies.cookies.set(messageStatus === FailedMessageStatus.Archived ? "all_deleted_messages_period" : "pending_retries_period", period);
+ if (!loaded) return;
+ if (controller) {
+ // need to cancel any existing fetch which otherwise will set messages of the incorrect period
+ controller.abort();
+ }
+ messages.value = [];
+ await refresh();
+ }
+
+ async function deleteById(ids: string[]) {
+ await serviceControlStore.patchToServiceControl("errors/archive", ids);
+ }
+
+ async function restoreById(ids: string[]) {
+ await serviceControlStore.patchToServiceControl("errors/unarchive", ids);
+ }
+
+ async function retryById(ids: string[]) {
+ await serviceControlStore.postToServiceControl("pendingretries/retry", ids);
+ }
+
+ async function resolveById(ids: string[]) {
+ await serviceControlStore.patchToServiceControl("pendingretries/resolve", { uniquemessageids: ids });
+ }
+
+ async function retryAll() {
+ let url = "pendingretries/retry";
+ const data: { from: string; to: string; queueaddress?: string } = {
+ from: new Date(0).toISOString(),
+ to: new Date(0).toISOString(),
+ };
+ if (selectedQueue.value !== "empty") {
+ url = "pendingretries/queues/retry";
+ data.queueaddress = selectedQueue.value;
+ }
+
+ await serviceControlStore.postToServiceControl(url, data);
+ }
+
+ async function resolveAll() {
+ await serviceControlStore.patchToServiceControl("pendingretries/resolve", { from: new Date(0).toISOString(), to: new Date().toISOString() });
+ }
+
+ async function clearSelectedQueue() {
+ selectedQueue.value = "empty";
+ await refresh();
+ }
+
+ return {
+ refresh,
+ messages,
+ perPage,
+ pageNumber,
+ totalCount,
+ groupId,
+ groupName,
+ sortDirection,
+ deletedPeriodOptions,
+ retryPeriodOptions,
+ selectedPeriod,
+ selectedQueue: shallowReadonly(selectedQueue),
+ endpoints: shallowReadonly(endpoints),
+ setSort,
+ setPeriod,
+ setMessageStatus,
+ deleteById,
+ restoreById,
+ retryById,
+ resolveById,
+ retryAll,
+ resolveAll,
+ clearSelectedQueue,
+ };
+});
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useRecoverabilityStore, import.meta.hot));
+}
+
+export type RecoverabilityStore = ReturnType;
diff --git a/src/Frontend/src/stores/ServiceControlStore.ts b/src/Frontend/src/stores/ServiceControlStore.ts
index f4dfe3954..a48307be4 100644
--- a/src/Frontend/src/stores/ServiceControlStore.ts
+++ b/src/Frontend/src/stores/ServiceControlStore.ts
@@ -44,8 +44,8 @@ export const useServiceControlStore = defineStore("ServiceControlStore", () => {
return await fetch(`${getServiceControlUrl()}${suffix}`, requestOptions);
}
- async function fetchTypedFromServiceControl(suffix: string): Promise<[Response, T]> {
- const response = await fetch(`${getServiceControlUrl()}${suffix}`);
+ async function fetchTypedFromServiceControl(suffix: string, signal?: AbortSignal): Promise<[Response, T]> {
+ const response = await fetch(`${getServiceControlUrl()}${suffix}`, { signal });
if (!response.ok) throw new Error(response.statusText ?? "No response");
const data = await response.json();