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() {
Auto-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) => {
+
- +
@@ -147,8 +171,14 @@ watch(autoRefreshValue, (newValue) => {
+
- + + @@ -169,6 +199,19 @@ watch(autoRefreshValue, (newValue) => { margin-top: -0.5rem; } +.query-error { + margin-top: 1rem; + padding: 0.75rem 1rem; + border: 1px solid #f0c2c2; + border-left: 4px solid #ce4844; + border-radius: 4px; + background-color: #fdf7f7; +} + +.query-error p { + margin: 0.25rem 0 0; +} + .results-table { margin-top: 1rem; margin-bottom: 5rem; diff --git a/src/Frontend/src/components/audit/FiltersPanel.vue b/src/Frontend/src/components/audit/FiltersPanel.vue index 6a0a361de..cc21d3cb5 100644 --- a/src/Frontend/src/components/audit/FiltersPanel.vue +++ b/src/Frontend/src/components/audit/FiltersPanel.vue @@ -6,7 +6,6 @@ import ListFilterSelector from "@/components/audit/ListFilterSelector.vue"; import { computed } from "vue"; import DatePickerRange from "@/components/audit/DatePickerRange.vue"; -const props = defineProps<{ queryInProgress?: boolean }>(); const store = useAuditStore(); const { sortBy, messageFilterString, selectedEndpointName, endpoints, itemsPerPage, dateRange } = storeToRefs(store); const endpointNames = computed(() => { @@ -60,42 +59,32 @@ function findKeyByValue(searchValue: string) {
- +
Check the documentation to see the available filtering options
Endpoint:
- +
Dates:
- +
Show:
- +
Sort:
- +
diff --git a/src/Frontend/src/components/audit/auditClient.ts b/src/Frontend/src/components/audit/auditClient.ts index 097aa9a69..92baf9d69 100644 --- a/src/Frontend/src/components/audit/auditClient.ts +++ b/src/Frontend/src/components/audit/auditClient.ts @@ -12,12 +12,13 @@ export interface AuditQuery { } class AuditClient { - public async getMessages(query: AuditQuery): Promise<[Response, Message[]]> { + public async getMessages(query: AuditQuery, signal?: AbortSignal): Promise<[Response, Message[]]> { const [fromDate, toDate] = query.dateRange ?? []; const from = fromDate?.toISOString() ?? ""; const to = toDate?.toISOString() ?? ""; return await serviceControlClient.fetchTypedFromServiceControl( - `messages2/?endpoint_name=${query.endpointName ?? ""}&from=${from}&to=${to}&q=${query.messageFilterString ?? ""}&page_size=${query.itemsPerPage ?? 100}&sort=${query.sort?.property ?? "time_sent"}&direction=${query.sort?.isAscending ? "asc" : "desc"}` + `messages2/?endpoint_name=${query.endpointName ?? ""}&from=${from}&to=${to}&q=${query.messageFilterString ?? ""}&page_size=${query.itemsPerPage ?? 100}&sort=${query.sort?.property ?? "time_sent"}&direction=${query.sort?.isAscending ? "asc" : "desc"}`, + signal ); } diff --git a/src/Frontend/src/composables/autoRefresh.spec.ts b/src/Frontend/src/composables/autoRefresh.spec.ts new file mode 100644 index 000000000..663178894 --- /dev/null +++ b/src/Frontend/src/composables/autoRefresh.spec.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { flushPromises } from "@vue/test-utils"; +import useFetchWithAutoRefresh from "@/composables/autoRefresh"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +describe("useFetchWithAutoRefresh", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("a poll tick during a running fetch does not cancel it, and refreshes once the results are in", async () => { + const slowFetch = deferred(); + const fetchFn = vi + .fn<() => Promise>() + .mockImplementationOnce(() => Promise.resolve()) // initial poll fetch on start + .mockImplementationOnce(() => slowFetch.promise) // the user-initiated long query + .mockImplementation(() => Promise.resolve()); + + const { refreshNow, start, isRefreshing } = useFetchWithAutoRefresh("test", fetchFn, 1000); + + await start(); + await flushPromises(); + expect(fetchFn).toHaveBeenCalledTimes(1); + + const userQuery = refreshNow(); + expect(fetchFn).toHaveBeenCalledTimes(2); + expect(isRefreshing.value).toBe(true); + + // The auto-refresh interval elapses while the user's query is still running + await vi.advanceTimersByTimeAsync(1100); + expect(fetchFn).toHaveBeenCalledTimes(2); // the running query was not disturbed + + slowFetch.resolve(); + await userQuery; + await flushPromises(); + + // The elapsed tick was honored once the results were in + expect(fetchFn).toHaveBeenCalledTimes(3); + expect(isRefreshing.value).toBe(false); + }); + + test("the next auto refresh time is exposed while polling and cleared when stopped", async () => { + const fetchFn = vi.fn<() => Promise>().mockResolvedValue(undefined); + const { start, stop, nextRefreshAt } = useFetchWithAutoRefresh("test", fetchFn, 1000); + + expect(nextRefreshAt.value).toBeNull(); + + await start(); + await flushPromises(); + + expect(nextRefreshAt.value).not.toBeNull(); + expect(nextRefreshAt.value! - Date.now()).toBeGreaterThan(0); + expect(nextRefreshAt.value! - Date.now()).toBeLessThanOrEqual(1000); + + stop(); + expect(nextRefreshAt.value).toBeNull(); + }); + + test("a user-initiated refresh is never dropped while a fetch is running", async () => { + const slowFetch = deferred(); + const fetchFn = vi + .fn<() => Promise>() + .mockImplementationOnce(() => slowFetch.promise) + .mockImplementation(() => Promise.resolve()); + + const { refreshNow } = useFetchWithAutoRefresh("test", fetchFn, 0); + + const first = refreshNow(); + const second = refreshNow(); // e.g. the user changed a filter while the first query was running + + expect(fetchFn).toHaveBeenCalledTimes(2); + + slowFetch.resolve(); + await Promise.all([first, second]); + }); +}); diff --git a/src/Frontend/src/composables/autoRefresh.ts b/src/Frontend/src/composables/autoRefresh.ts index 493f0c633..458fb5849 100644 --- a/src/Frontend/src/composables/autoRefresh.ts +++ b/src/Frontend/src/composables/autoRefresh.ts @@ -1,22 +1,47 @@ -import { watch, ref, shallowReadonly, type WatchStopHandle } from "vue"; +import { watch, ref, computed, shallowReadonly, type Ref, type WatchStopHandle } from "vue"; import { useCounter, useDocumentVisibility, useTimeoutPoll } from "@vueuse/core"; export default function useFetchWithAutoRefresh(_name: string, fetchFn: () => Promise, intervalMs: number) { let watchStop: WatchStopHandle | null = null; const { count, inc, dec, reset } = useCounter(0); const interval = ref(intervalMs); - const isRefreshing = ref(false); - const fetchWrapper = async () => { - if (isRefreshing.value) { - return; - } - isRefreshing.value = true; + const inflight = ref(0); + const refreshPending = ref(false); + const isRefreshing = computed(() => inflight.value > 0); + // When auto-refresh is active, the moment (epoch ms) the next poll tick is due; null while inactive + const nextRefreshAt = ref(null); + + const run = async () => { + inflight.value++; try { await fetchFn(); } finally { - isRefreshing.value = false; + inflight.value--; + } + + if (inflight.value === 0 && refreshPending.value) { + // A poll tick landed while this fetch was running: the results were awaited, now honor the tick + refreshPending.value = false; + await run(); } }; + + // Poll path: a tick that lands while a fetch is already running must not cancel it — + // it marks a refresh as pending, which runs as soon as the current fetch completes + const fetchWrapper = async () => { + if (inflight.value > 0) { + refreshPending.value = true; + } else { + await run(); + } + + if (isActive.value) { + nextRefreshAt.value = Date.now() + interval.value; + } + }; + + // User path: never dropped — the fetchFn is expected to supersede its own in-flight work + const refreshNow = run; const { isActive, pause, resume } = useTimeoutPoll( fetchWrapper, interval, @@ -25,6 +50,11 @@ export default function useFetchWithAutoRefresh(_name: string, fetchFn: () => Pr const visibility = useDocumentVisibility(); + const pausePolling = () => { + pause(); + nextRefreshAt.value = null; + }; + const start = async () => { inc(); if (count.value === 1) { @@ -35,7 +65,7 @@ export default function useFetchWithAutoRefresh(_name: string, fetchFn: () => Pr } if (current === "hidden" && previous === "visible") { - pause(); + pausePolling(); } }); } else { @@ -47,7 +77,7 @@ export default function useFetchWithAutoRefresh(_name: string, fetchFn: () => Pr const stop = () => { dec(); if (count.value <= 0) { - pause(); + pausePolling(); watchStop?.(); watchStop = null; reset(); @@ -66,5 +96,5 @@ export default function useFetchWithAutoRefresh(_name: string, fetchFn: () => Pr } }; - return { refreshNow: fetchWrapper, isRefreshing: shallowReadonly(isRefreshing), updateInterval, isActive, start, stop }; + return { refreshNow, isRefreshing: isRefreshing as Readonly>, updateInterval, isActive, start, stop, nextRefreshAt: shallowReadonly(nextRefreshAt) }; } diff --git a/src/Frontend/src/stores/AuditStore.spec.ts b/src/Frontend/src/stores/AuditStore.spec.ts new file mode 100644 index 000000000..d121db502 --- /dev/null +++ b/src/Frontend/src/stores/AuditStore.spec.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; + +const { fetchTypedFromServiceControl } = vi.hoisted(() => ({ + fetchTypedFromServiceControl: vi.fn(), +})); + +vi.mock("@/components/serviceControlClient", () => ({ + default: { + fetchTypedFromServiceControl, + }, +})); + +import { useAuditStore } from "@/stores/AuditStore"; + +function responseWithTotalCount(count: number): Response { + return { headers: new Headers({ "total-count": count.toString() }) } as Response; +} + +function abortablePendingFetch(onSignal?: (signal: AbortSignal | undefined) => void) { + return (_suffix: string, signal?: AbortSignal) => + new Promise((_resolve, reject) => { + onSignal?.(signal); + signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + }); +} + +const message = { id: "msg-1" }; + +describe("AuditStore refresh", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + test("a successful query populates the messages and total count", async () => { + fetchTypedFromServiceControl.mockResolvedValue([responseWithTotalCount(1), [message]]); + const store = useAuditStore(); + + await store.refresh(); + + expect(store.messages).toEqual([message]); + expect(store.totalCount).toBe(1); + expect(store.queryFailed).toBe(false); + }); + + test("a failed query flags the failure instead of throwing", async () => { + fetchTypedFromServiceControl.mockRejectedValue(new Error("Internal Server Error")); + const store = useAuditStore(); + + await expect(store.refresh()).resolves.toBeUndefined(); + + expect(store.messages).toEqual([]); + expect(store.totalCount).toBe(0); + expect(store.queryFailed).toBe(true); + }); + + test("a successful query clears an earlier failure", async () => { + const store = useAuditStore(); + + fetchTypedFromServiceControl.mockRejectedValueOnce(new Error("Internal Server Error")); + await store.refresh(); + expect(store.queryFailed).toBe(true); + + fetchTypedFromServiceControl.mockResolvedValue([responseWithTotalCount(1), [message]]); + await store.refresh(); + + expect(store.queryFailed).toBe(false); + expect(store.messages).toEqual([message]); + }); + + test("a new query aborts the one still in flight and keeps the newer result", async () => { + const store = useAuditStore(); + + let firstSignal: AbortSignal | undefined; + fetchTypedFromServiceControl.mockImplementationOnce(abortablePendingFetch((signal) => (firstSignal = signal))); + + const first = store.refresh(); + + const newerMessage = { id: "msg-2" }; + fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(1), [newerMessage]]); + await store.refresh(); + await first; + + expect(firstSignal?.aborted).toBe(true); + expect(store.messages).toEqual([newerMessage]); + expect(store.queryFailed).toBe(false); + }); + + test("cancelQuery aborts the query in flight without reporting a failure", async () => { + const store = useAuditStore(); + + let signal: AbortSignal | undefined; + fetchTypedFromServiceControl.mockImplementationOnce(abortablePendingFetch((s) => (signal = s))); + + const inFlight = store.refresh(); + store.cancelQuery(); + await inFlight; + + expect(signal?.aborted).toBe(true); + expect(store.queryFailed).toBe(false); + }); + + test("clearResults forgets the current results and any failure", async () => { + const store = useAuditStore(); + fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(1), [message]]); + await store.refresh(); + fetchTypedFromServiceControl.mockRejectedValueOnce(new Error("Internal Server Error")); + await store.refresh(); + expect(store.queryFailed).toBe(true); + + store.clearResults(); + + expect(store.messages).toEqual([]); + expect(store.totalCount).toBe(0); + expect(store.queryFailed).toBe(false); + }); + + test("a superseded query is not reported as a failure", async () => { + const store = useAuditStore(); + + fetchTypedFromServiceControl.mockImplementationOnce(abortablePendingFetch()); + + const first = store.refresh(); + + fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(0), []]); + await store.refresh(); + await first; + + expect(store.queryFailed).toBe(false); + }); +}); diff --git a/src/Frontend/src/stores/AuditStore.ts b/src/Frontend/src/stores/AuditStore.ts index 027a24f79..759f7fce3 100644 --- a/src/Frontend/src/stores/AuditStore.ts +++ b/src/Frontend/src/stores/AuditStore.ts @@ -27,6 +27,8 @@ export const useAuditStore = defineStore("AuditStore", () => { const messages = ref([]); const selectedEndpointName = ref(""); const endpoints = ref([]); + const queryFailed = ref(false); + let activeQuery: AbortController | null = null; async function loadEndpoints() { try { @@ -39,24 +41,72 @@ export const useAuditStore = defineStore("AuditStore", () => { } async function refresh() { + // A refresh always represents the latest query the user asked for, so any query still in + // flight is stale: abort it (which also terminates it on the ServiceControl side) and let + // this one own the view state. + activeQuery?.abort(); + const thisQuery = new AbortController(); + activeQuery = thisQuery; + try { - const [response, data] = await auditClient.getMessages({ - endpointName: selectedEndpointName.value, - dateRange: dateRange.value, - messageFilterString: messageFilterString.value, - itemsPerPage: itemsPerPage.value, - sort: sortByInstances.value, - }); + const [response, data] = await auditClient.getMessages( + { + endpointName: selectedEndpointName.value, + dateRange: dateRange.value, + messageFilterString: messageFilterString.value, + itemsPerPage: itemsPerPage.value, + sort: sortByInstances.value, + }, + thisQuery.signal + ); + + if (activeQuery !== thisQuery) { + return; + } + totalCount.value = parseInt(response.headers.get("total-count") ?? "0"); messages.value = data; - } catch (e) { + queryFailed.value = false; + } catch { + if (thisQuery.signal.aborted) { + // Superseded by a newer query, which owns the view state from here on + return; + } + + // A long-running query is terminated by ServiceControl after its configured query time limit + // and surfaces here as a failed response. Not rethrown: the callers are watchers, so a + // rethrow would only become an unhandled rejection instead of user feedback. messages.value = []; - throw e; + totalCount.value = 0; + queryFailed.value = true; + } finally { + if (activeQuery === thisQuery) { + activeQuery = null; + } } } + // Stops the in-flight query, e.g. when the view showing the results is left. + // The abort propagates through the ServiceControl API and terminates the + // database query, so a backgrounded view does not keep load on the server. + function cancelQuery() { + activeQuery?.abort(); + activeQuery = null; + } + + // Forgets the current results. The view calls this when it is left: a refresh in place + // keeps stale rows on purpose, but re-entering the view may come with different query + // inputs, so it must start from a clean list rather than the previous one under a spinner. + function clearResults() { + messages.value = []; + totalCount.value = 0; + queryFailed.value = false; + } + return { refresh, + cancelQuery, + clearResults, loadEndpoints, sortBy: sortByInstances, messages, @@ -66,6 +116,7 @@ export const useAuditStore = defineStore("AuditStore", () => { totalCount, endpoints, dateRange, + queryFailed, }; });