diff --git a/src/Frontend/src/components/AutoRefreshIndicator.spec.ts b/src/Frontend/src/components/AutoRefreshIndicator.spec.ts
new file mode 100644
index 000000000..c5e766f7e
--- /dev/null
+++ b/src/Frontend/src/components/AutoRefreshIndicator.spec.ts
@@ -0,0 +1,28 @@
+import { describe, expect, test } from "vitest";
+import { render, screen } from "@testing-library/vue";
+import AutoRefreshIndicator from "@/components/AutoRefreshIndicator.vue";
+
+describe("FEATURE: Auto refresh indicator", () => {
+ test("EXAMPLE: Nothing is rendered while auto refresh is off", () => {
+ render(AutoRefreshIndicator, { props: { nextRefreshAt: null, intervalMs: 5000, refreshing: false } });
+
+ expect(screen.queryByTestId("auto-refresh-indicator")).not.toBeInTheDocument();
+ });
+
+ test("EXAMPLE: A countdown bar is shown while waiting for the next refresh", () => {
+ render(AutoRefreshIndicator, { props: { nextRefreshAt: Date.now() + 2500, intervalMs: 5000, refreshing: false } });
+
+ expect(screen.getByTestId("auto-refresh-indicator")).toBeInTheDocument();
+ const bar = screen.getByTestId("auto-refresh-countdown");
+ const width = parseFloat(bar.style.width);
+ expect(width).toBeGreaterThan(25);
+ expect(width).toBeLessThan(75);
+ });
+
+ test("EXAMPLE: A waiting state is shown when the refresh is due but the query is still running", () => {
+ render(AutoRefreshIndicator, { props: { nextRefreshAt: Date.now(), intervalMs: 5000, refreshing: true } });
+
+ expect(screen.getByTestId("auto-refresh-waiting")).toBeInTheDocument();
+ expect(screen.queryByTestId("auto-refresh-countdown")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/Frontend/src/components/AutoRefreshIndicator.vue b/src/Frontend/src/components/AutoRefreshIndicator.vue
new file mode 100644
index 000000000..773cb70ea
--- /dev/null
+++ b/src/Frontend/src/components/AutoRefreshIndicator.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
diff --git a/src/Frontend/src/components/RefreshConfig.spec.ts b/src/Frontend/src/components/RefreshConfig.spec.ts
index cbdb02cd5..ff74b7fa7 100644
--- a/src/Frontend/src/components/RefreshConfig.spec.ts
+++ b/src/Frontend/src/components/RefreshConfig.spec.ts
@@ -66,8 +66,8 @@ function renderRefreshConfig(queryInProgress: boolean) {
// ==================== Tests ====================
describe("FEATURE: Refresh Controls Query State", () => {
- describe("RULE: Refresh controls are disabled while a query is in progress", () => {
- test("EXAMPLE: Refresh controls reflect queryInProgress changes", async () => {
+ describe("RULE: Only the refresh action locks while a query is in progress", () => {
+ test("EXAMPLE: The refresh button loads and disables, the auto-refresh selector stays usable", async () => {
const { setQueryInProgress, verify } = renderRefreshConfig(false);
verify.refreshButtonIsNotLoading();
@@ -78,7 +78,8 @@ describe("FEATURE: Refresh Controls Query State", () => {
verify.refreshButtonIsLoading();
verify.refreshButtonIsDisabled();
- verify.autoRefreshSelectorIsDisabled();
+ // The selector must stay usable so auto-refresh can be turned off during a slow query
+ verify.autoRefreshSelectorIsEnabled();
await setQueryInProgress(false);
diff --git a/src/Frontend/src/components/RefreshConfig.vue b/src/Frontend/src/components/RefreshConfig.vue
index a271ef864..2fe5acac2 100644
--- a/src/Frontend/src/components/RefreshConfig.vue
+++ b/src/Frontend/src/components/RefreshConfig.vue
@@ -54,7 +54,7 @@ async function refresh() {
diff --git a/src/Frontend/src/components/audit/AuditList.spec.ts b/src/Frontend/src/components/audit/AuditList.spec.ts
index 622a6ca16..14a4d2cb5 100644
--- a/src/Frontend/src/components/audit/AuditList.spec.ts
+++ b/src/Frontend/src/components/audit/AuditList.spec.ts
@@ -41,15 +41,16 @@ interface QueryStateAssertions {
messagesAreNotVisible(): void;
refreshControlsKnowQueryIsInProgress(): void;
refreshControlsKnowQueryIsIdle(): void;
- filtersKnowQueryIsInProgress(): void;
- filtersKnowQueryIsIdle(): void;
+ filtersAreNotBlockedByQuery(): void;
}
interface RenderResult {
verify: QueryStateAssertions;
isRefreshing: Ref;
refreshNow: ReturnType;
+ stop: ReturnType;
store: ReturnType;
+ unmount: () => void;
}
// ==================== DOM Query Helpers ====================
@@ -105,9 +106,13 @@ function createMessage(id = "msg-1"): Message {
// ==================== Component Renderer ====================
-async function renderAuditList(messages: Message[] = []): Promise {
+async function renderAuditList(messages: Message[] = [], options: { neverCompleteFirstQuery?: boolean } = {}): Promise {
const isRefreshing = ref(false);
const refreshNow = vi.fn().mockResolvedValue(undefined);
+ if (options.neverCompleteFirstQuery) {
+ refreshNow.mockImplementationOnce(() => new Promise(() => {}));
+ }
+ const stop = vi.fn();
vi.mocked(useFetchWithAutoRefresh).mockReturnValue({
refreshNow,
@@ -115,7 +120,8 @@ async function renderAuditList(messages: Message[] = []): Promise
updateInterval: vi.fn(),
isActive: ref(false),
start: vi.fn(),
- stop: vi.fn(),
+ stop,
+ nextRefreshAt: shallowReadonly(ref(null)),
});
const router = createRouter({
@@ -133,7 +139,7 @@ async function renderAuditList(messages: Message[] = []): Promise
},
});
- render(AuditList, {
+ const { unmount } = render(AuditList, {
global: {
plugins: [pinia, router],
stubs: {
@@ -172,15 +178,14 @@ async function renderAuditList(messages: Message[] = []): Promise
refreshControlsKnowQueryIsIdle() {
expect(getRefreshConfig().dataset.queryInProgress).toBe("false");
},
- filtersKnowQueryIsInProgress() {
- expect(getFiltersPanel().dataset.queryInProgress).toBe("true");
- },
- filtersKnowQueryIsIdle() {
- expect(getFiltersPanel().dataset.queryInProgress).toBe("false");
+ filtersAreNotBlockedByQuery() {
+ // The filters panel is deliberately not told about query progress: entering a new
+ // query must always be possible, even while a slow query is still running.
+ expect(getFiltersPanel().dataset.queryInProgress).toBe("undefined");
},
};
- return { verify, isRefreshing, refreshNow, store: useAuditStore(pinia) };
+ return { verify, isRefreshing, refreshNow, stop, store: useAuditStore(pinia), unmount };
}
// A control change reaches the fetch via two async hops (controls watcher -> router.push -> route watcher),
@@ -213,7 +218,7 @@ describe("FEATURE: Audit Messages Query State", () => {
verify.spinnerIsVisible();
verify.messagesAreNotVisible();
verify.refreshControlsKnowQueryIsInProgress();
- verify.filtersKnowQueryIsInProgress();
+ verify.filtersAreNotBlockedByQuery();
});
test("EXAMPLE: Spinner is hidden after the first fetch completes", async () => {
@@ -224,7 +229,7 @@ describe("FEATURE: Audit Messages Query State", () => {
await waitFor(() => verify.spinnerIsNotVisible());
verify.overlayIsNotVisible();
verify.refreshControlsKnowQueryIsIdle();
- verify.filtersKnowQueryIsIdle();
+ verify.filtersAreNotBlockedByQuery();
});
});
@@ -256,8 +261,8 @@ describe("FEATURE: Audit Messages Query State", () => {
});
});
- describe("RULE: Query controls are disabled during a fetch", () => {
- test("EXAMPLE: Query controls are disabled when a re-fetch is in-flight", async () => {
+ describe("RULE: Filters stay usable while a query is running", () => {
+ test("EXAMPLE: The filters are not blocked when a re-fetch is in-flight", async () => {
const { verify, isRefreshing } = await renderAuditList([]);
await waitForFirstLoadToComplete();
@@ -266,10 +271,10 @@ describe("FEATURE: Audit Messages Query State", () => {
await nextTick();
verify.refreshControlsKnowQueryIsInProgress();
- verify.filtersKnowQueryIsInProgress();
+ verify.filtersAreNotBlockedByQuery();
});
- test("EXAMPLE: Query controls are re-enabled after the fetch completes", async () => {
+ test("EXAMPLE: The refresh action is re-enabled after the fetch completes", async () => {
const { verify, isRefreshing } = await renderAuditList([]);
await waitForFirstLoadToComplete();
@@ -281,36 +286,60 @@ describe("FEATURE: Audit Messages Query State", () => {
await nextTick();
verify.refreshControlsKnowQueryIsIdle();
- verify.filtersKnowQueryIsIdle();
+ verify.filtersAreNotBlockedByQuery();
});
});
- describe("RULE: A loading overlay is shown when re-fetching with existing results", () => {
- test("EXAMPLE: Overlay appears over existing messages while a re-fetch is in-flight", async () => {
+ describe("RULE: Existing results stay visible and usable while a re-fetch runs", () => {
+ // The refresh button already signals the running query (spinner, Cancel, elapsed time).
+ // Covering the stale rows would only take them away from the user while waiting.
+ test("EXAMPLE: No overlay covers the rows during a re-fetch, however long it takes", async () => {
const { verify, isRefreshing } = await renderAuditList([createMessage()]);
-
await waitForFirstLoadToComplete();
await waitFor(() => verify.messagesAreVisible());
isRefreshing.value = true;
await nextTick();
+ verify.overlayIsNotVisible();
+ verify.spinnerIsNotVisible();
- verify.overlayIsVisible();
+ await new Promise((r) => setTimeout(r, 400));
+ verify.overlayIsNotVisible();
verify.messagesAreVisible();
});
+ });
- test("EXAMPLE: Overlay disappears after the re-fetch completes", async () => {
- const { verify, isRefreshing } = await renderAuditList([createMessage()]);
+ describe("RULE: A failed query tells the user what happened and what to try", () => {
+ test("EXAMPLE: The error banner is shown after a failed query", async () => {
+ const { store } = await renderAuditList([]);
await waitForFirstLoadToComplete();
- isRefreshing.value = true;
+ store.queryFailed = true;
await nextTick();
- verify.overlayIsVisible();
- isRefreshing.value = false;
+ expect(screen.getByTestId("query-error")).toBeInTheDocument();
+ });
+
+ test("EXAMPLE: The error banner is not shown while a retry is in flight", async () => {
+ const { store, isRefreshing } = await renderAuditList([]);
+
+ await waitForFirstLoadToComplete();
+
+ store.queryFailed = true;
+ isRefreshing.value = true;
await nextTick();
- verify.overlayIsNotVisible();
+
+ expect(screen.queryByTestId("query-error")).not.toBeInTheDocument();
+ });
+
+ test("EXAMPLE: The error banner is not shown when queries succeed", async () => {
+ const { verify } = await renderAuditList([createMessage()]);
+
+ await waitForFirstLoadToComplete();
+
+ verify.messagesAreVisible();
+ expect(screen.queryByTestId("query-error")).not.toBeInTheDocument();
});
});
@@ -327,6 +356,19 @@ describe("FEATURE: Audit Messages Query State", () => {
expect(refreshNow.mock.calls.length - queriesAfterFirstLoad).toBe(1);
});
+ test("EXAMPLE: Typing a search during the slow initial query still starts the new query", async () => {
+ const { refreshNow, store } = await renderAuditList([], { neverCompleteFirstQuery: true });
+
+ await waitForFirstLoadToComplete();
+ expect(refreshNow).toHaveBeenCalledTimes(1); // the initial query, still running
+
+ store.messageFilterString = "orders";
+ await waitForRouteDrivenQuery();
+
+ // The new query must not be swallowed just because the first one never finished
+ expect(refreshNow).toHaveBeenCalledTimes(2);
+ });
+
test("EXAMPLE: Changing the endpoint fires a single query", async () => {
const { refreshNow, store } = await renderAuditList([createMessage()]);
@@ -339,4 +381,29 @@ describe("FEATURE: Audit Messages Query State", () => {
expect(refreshNow.mock.calls.length - queriesAfterFirstLoad).toBe(1);
});
});
+
+ describe("RULE: Leaving the view stops its activity", () => {
+ test("EXAMPLE: Unmounting aborts the in-flight query and releases the auto-refresh", async () => {
+ const { stop, store, unmount } = await renderAuditList([createMessage()], { neverCompleteFirstQuery: true });
+
+ await waitForFirstLoadToComplete();
+
+ unmount();
+
+ expect(store.cancelQuery).toHaveBeenCalled();
+ expect(stop).toHaveBeenCalled();
+ });
+
+ test("EXAMPLE: Unmounting clears the results, so re-entering the view starts from a clean list", async () => {
+ // A refresh-in-place keeps stale rows on purpose; coming back to the view is not that:
+ // the query inputs may differ, so the previous rows must not be shown under the spinner
+ const { store, unmount } = await renderAuditList([createMessage()]);
+
+ await waitForFirstLoadToComplete();
+
+ unmount();
+
+ expect(store.clearResults).toHaveBeenCalled();
+ });
+ });
});
diff --git a/src/Frontend/src/components/audit/AuditList.vue b/src/Frontend/src/components/audit/AuditList.vue
index 19dbd77a7..053cd8aeb 100644
--- a/src/Frontend/src/components/audit/AuditList.vue
+++ b/src/Frontend/src/components/audit/AuditList.vue
@@ -5,8 +5,9 @@ import { useRoute, useRouter } from "vue-router";
import ResultsCount from "@/components/ResultsCount.vue";
import FiltersPanel from "@/components/audit/FiltersPanel.vue";
import AuditListItem from "@/components/audit/AuditListItem.vue";
-import { computed, onBeforeMount, ref, watch } from "vue";
+import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from "vue";
import RefreshConfig from "../RefreshConfig.vue";
+import AutoRefreshIndicator from "../AutoRefreshIndicator.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import useFetchWithAutoRefresh from "@/composables/autoRefresh";
import WizardDialog from "@/components/platformcapabilities/WizardDialog.vue";
@@ -17,11 +18,11 @@ import PageBanner, { type BannerMessage } from "@/components/PageBanner.vue";
import { useConfigurationStore } from "@/stores/ConfigurationStore";
const store = useAuditStore();
-const { messages, totalCount, sortBy, messageFilterString, selectedEndpointName, itemsPerPage, dateRange } = storeToRefs(store);
+const { messages, totalCount, sortBy, messageFilterString, selectedEndpointName, itemsPerPage, dateRange, queryFailed } = storeToRefs(store);
const route = useRoute();
const router = useRouter();
const autoRefreshValue = ref(null);
-const { refreshNow, isRefreshing, updateInterval, isActive, start, stop } = useFetchWithAutoRefresh("audit-list", store.refresh, 0);
+const { refreshNow, isRefreshing, updateInterval, isActive, start, stop, nextRefreshAt } = useFetchWithAutoRefresh("audit-list", store.refresh, 0);
const firstLoad = ref(true);
const queryInProgress = computed(() => firstLoad.value || isRefreshing.value);
const showWizard = ref(false);
@@ -72,6 +73,16 @@ onBeforeMount(() => {
}, 0);
});
+onBeforeUnmount(() => {
+ // Leaving the view stops all of its activity: the auto-refresh poll is released and the
+ // in-flight query is aborted so it does not keep running (server-side included) in the background
+ stop();
+ store.cancelQuery();
+ // The rows belong to this visit: the next one may carry different query inputs, so it
+ // starts from a clean list (a refresh in place, by contrast, keeps the stale rows)
+ store.clearResults();
+});
+
// The route is the single source of truth for the query: control changes only push to the router,
// and only a route change triggers a fetch. Having the fetch in both watchers (and the route in the
// controls watcher) made a single control change fire the same query up to three times.
@@ -83,26 +94,36 @@ watch(
}
);
+function controlsQuery() {
+ const [fromDate, toDate] = dateRange.value;
+
+ return {
+ sortBy: sortBy.value.property,
+ sortDir: sortBy.value.isAscending ? "asc" : "desc",
+ filter: messageFilterString.value,
+ endpoint: selectedEndpointName.value,
+ from: fromDate?.toISOString() ?? "",
+ to: toDate?.toISOString() ?? "",
+ pageSize: itemsPerPage.value,
+ };
+}
+
+// The serialized controls state the route last applied (via setQuery) or that was last pushed.
+// The controls watcher only pushes when the controls actually moved away from this, which makes
+// it safe to react to changes at any time — including while the first (possibly very slow)
+// query is still running, so a user typing a search is never ignored.
+let lastAppliedControlsQuery = "";
+
const watchHandle = watch([itemsPerPage, sortBy, messageFilterString, selectedEndpointName, dateRange], async () => {
- if (firstLoad.value) {
+ const query = controlsQuery();
+ const serialized = JSON.stringify(query);
+
+ if (serialized === lastAppliedControlsQuery) {
return;
}
- const [fromDate, toDate] = dateRange.value;
- const from = fromDate?.toISOString() ?? "";
- const to = toDate?.toISOString() ?? "";
-
- await router.push({
- query: {
- sortBy: sortBy.value.property,
- sortDir: sortBy.value.isAscending ? "asc" : "desc",
- filter: messageFilterString.value,
- endpoint: selectedEndpointName.value,
- from,
- to,
- pageSize: itemsPerPage.value,
- },
- });
+ lastAppliedControlsQuery = serialized;
+ await router.push({ query });
});
function setQuery() {
@@ -119,6 +140,8 @@ function setQuery() {
dateRange.value = query.from && query.to ? [new Date(query.from as string), new Date(query.to as string)] : [];
selectedEndpointName.value = (query.endpoint ?? "") as string;
+ lastAppliedControlsQuery = JSON.stringify(controlsQuery());
+
watchHandle.resume();
}
@@ -138,8 +161,9 @@ watch(autoRefreshValue, (newValue) => {