From e5a8d15a98df1323e2b30095d34b40b26f4d064d Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 3 Sep 2026 18:44:54 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20Stop=20the=20unbounded=20cap?= =?UTF-8?q?ability=20probe=20from=20hammering=20the=20audit=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The very-long query on first view access was not the message list (which is bounded by the default time range) but the hasSuccessfulMessages capability probe: an UNBOUNDED sorted query over the whole audit index, re-fired every 5 seconds by the platform-capabilities poll. It now probes the last 7 days first, falls back to unbounded only when that window is empty, and stops querying entirely once successful messages have been seen — they don't un-exist. --- .../src/components/audit/auditClient.ts | 9 ++-- src/Frontend/src/stores/AuditStore.spec.ts | 15 ++++++ src/Frontend/src/stores/AuditStore.ts | 3 +- .../stores/PlatformCapabilitiesStore.spec.ts | 53 +++++++++++++++++++ .../src/stores/PlatformCapabilitiesStore.ts | 10 +++- 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts diff --git a/src/Frontend/src/components/audit/auditClient.ts b/src/Frontend/src/components/audit/auditClient.ts index 92baf9d698..feb12cf9e6 100644 --- a/src/Frontend/src/components/audit/auditClient.ts +++ b/src/Frontend/src/components/audit/auditClient.ts @@ -22,10 +22,13 @@ class AuditClient { ); } - public async hasSuccessfulMessages(): Promise { + public async hasSuccessfulMessages(from?: Date): Promise { // Fetch the latest 10 messages and check if any are successful - // ideally we would want to filter successful messages server-side, but the API doesn't currently support that - const [, data] = await serviceControlClient.fetchTypedFromServiceControl(`messages2/?page_size=10&sort=time_sent&direction=desc`); + // ideally we would want to filter successful messages server-side, but the API doesn't currently support that. + // An unbounded sorted query scans the whole audit index, so callers should probe a bounded + // window first and only fall back to the unbounded form when the window is empty. + const bound = from ? `&from=${from.toISOString()}` : ""; + const [, data] = await serviceControlClient.fetchTypedFromServiceControl(`messages2/?page_size=10&sort=time_sent&direction=desc${bound}`); return data?.some((msg) => msg.status === MessageStatus.Successful) ?? false; } } diff --git a/src/Frontend/src/stores/AuditStore.spec.ts b/src/Frontend/src/stores/AuditStore.spec.ts index d121db502f..0d75d61440 100644 --- a/src/Frontend/src/stores/AuditStore.spec.ts +++ b/src/Frontend/src/stores/AuditStore.spec.ts @@ -31,6 +31,21 @@ describe("AuditStore refresh", () => { beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); + localStorage.clear(); + }); + + test("the default time range bounds the very first query", async () => { + fetchTypedFromServiceControl.mockResolvedValue([responseWithTotalCount(0), []]); + const store = useAuditStore(); + + await store.refresh(); + + const url = fetchTypedFromServiceControl.mock.calls[0][0] as string; + const params = new URLSearchParams(url.split("?")[1]); + expect(store.timeRangeFrom).toBe("now-6h"); + expect(params.get("from")).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(params.get("to")).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(new Date(params.get("to")!).getTime() - new Date(params.get("from")!).getTime()).toBe(6 * 3600 * 1000); }); test("a successful query populates the messages and total count", async () => { diff --git a/src/Frontend/src/stores/AuditStore.ts b/src/Frontend/src/stores/AuditStore.ts index 87511f9ab3..39980348e0 100644 --- a/src/Frontend/src/stores/AuditStore.ts +++ b/src/Frontend/src/stores/AuditStore.ts @@ -69,6 +69,7 @@ export const useAuditStore = defineStore("AuditStore", () => { ); if (activeQuery !== thisQuery) { + // a newer query took over the view state while this one was in flight return; } @@ -77,7 +78,7 @@ export const useAuditStore = defineStore("AuditStore", () => { queryFailed.value = false; } catch { if (thisQuery.signal.aborted) { - // Superseded by a newer query, which owns the view state from here on + // Superseded by a newer query, or the view was left return; } diff --git a/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts b/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts new file mode 100644 index 0000000000..b2a9e11433 --- /dev/null +++ b/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; + +const auditClient = vi.hoisted(() => ({ hasSuccessfulMessages: vi.fn() })); +vi.mock("@/components/audit/auditClient", () => ({ default: auditClient })); +vi.mock("@/components/monitoring/monitoringClient", () => ({ default: { isMonitoringEnabled: false } })); +vi.mock("@/components/serviceControlClient", () => ({ default: { url: undefined } })); +vi.mock("@/composables/useConnectionsAndStatsAutoRefresh", () => ({ + default: () => ({ store: { monitoringConnectionState: { unableToConnect: true } } }), +})); + +import { usePlatformCapabilitiesStore } from "@/stores/PlatformCapabilitiesStore"; + +describe("FEATURE: Successful-messages capability probe", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + localStorage.clear(); + }); + + test("EXAMPLE: A bounded window is probed before falling back to the full store", async () => { + auditClient.hasSuccessfulMessages.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const store = usePlatformCapabilitiesStore(); + + await store.refresh(); + + expect(auditClient.hasSuccessfulMessages).toHaveBeenCalledTimes(2); + expect(auditClient.hasSuccessfulMessages.mock.calls[0][0]).toBeInstanceOf(Date); // bounded first + expect(auditClient.hasSuccessfulMessages.mock.calls[1][0]).toBeUndefined(); // unbounded fallback + expect(store.hasSuccessfulMessages).toBe(true); + }); + + test("EXAMPLE: A hit in the bounded window skips the expensive unbounded probe", async () => { + auditClient.hasSuccessfulMessages.mockResolvedValueOnce(true); + const store = usePlatformCapabilitiesStore(); + + await store.refresh(); + + expect(auditClient.hasSuccessfulMessages).toHaveBeenCalledTimes(1); + expect(store.hasSuccessfulMessages).toBe(true); + }); + + test("EXAMPLE: Once successful messages were seen, later refreshes stop probing", async () => { + auditClient.hasSuccessfulMessages.mockResolvedValue(true); + const store = usePlatformCapabilitiesStore(); + + await store.refresh(); + await store.refresh(); + await store.refresh(); + + expect(auditClient.hasSuccessfulMessages).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/Frontend/src/stores/PlatformCapabilitiesStore.ts b/src/Frontend/src/stores/PlatformCapabilitiesStore.ts index b7a275c12b..f3cea6d332 100644 --- a/src/Frontend/src/stores/PlatformCapabilitiesStore.ts +++ b/src/Frontend/src/stores/PlatformCapabilitiesStore.ts @@ -61,8 +61,16 @@ export const usePlatformCapabilitiesStore = defineStore("PlatformCapabilitiesSto const hasMonitoredEndpoints = ref(false); async function checkForSuccessfulMessages() { + // Successful messages don't un-exist: once seen, never query again. + // The unbounded form of this probe scans the entire audit index (minutes on large + // stores), so probe a recent window first and fall back to unbounded only when + // the window is empty. + if (hasSuccessfulMessages.value) { + return; + } try { - hasSuccessfulMessages.value = await auditClient.hasSuccessfulMessages(); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000); + hasSuccessfulMessages.value = (await auditClient.hasSuccessfulMessages(sevenDaysAgo)) || (await auditClient.hasSuccessfulMessages()); } catch { hasSuccessfulMessages.value = false; } From e1cdc46778e8d9ef941e750b5ca25bdc6111ed2b Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Tue, 1 Sep 2026 18:20:19 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20Render=20onboarding=20prompt?= =?UTF-8?q?s=20only=20after=20the=20capability=20probe=20has=20answered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'no successful audit messages' onboarding banner and dashboard card assumed 'no messages' by default (the flag started false) and un-rendered once the probe proved otherwise — on large stores that meant minutes of a wrong onboarding prompt, and on every fresh session a flicker. hasSuccessfulMessages is now tri-state: null (not yet determined), false (a completed probe found nothing), true. While unknown the auditing capability reports a new Checking status: the All Messages banner renders nothing and the dashboard card shows its loading overlay. A failed probe proves nothing about the data, so it leaves the state unknown and the next tick retries instead of claiming 'no messages'. --- .../src/components/audit/AuditList.spec.ts | 15 +++++++++- .../capabilities/AuditingCapability.ts | 4 +-- .../stores/PlatformCapabilitiesStore.spec.ts | 28 +++++++++++++++++++ .../src/stores/PlatformCapabilitiesStore.ts | 8 ++++-- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/Frontend/src/components/audit/AuditList.spec.ts b/src/Frontend/src/components/audit/AuditList.spec.ts index ee80387842..ea2824fd5b 100644 --- a/src/Frontend/src/components/audit/AuditList.spec.ts +++ b/src/Frontend/src/components/audit/AuditList.spec.ts @@ -21,8 +21,9 @@ import { type default as Message, MessageStatus } from "@/resources/Message"; // ==================== Mock Setup ==================== vi.mock("@/composables/autoRefresh"); +const auditingStatus = vi.hoisted(() => ({ value: "Available" })); vi.mock("@/components/platformcapabilities/capabilities/AuditingCapability", () => ({ - useAuditingCapability: () => ({ status: { value: "Available" } }), + useAuditingCapability: () => ({ status: auditingStatus }), })); vi.mock("@/components/platformcapabilities/wizards/AuditingWizardPages", () => ({ getAuditingWizardPages: () => [], @@ -209,6 +210,7 @@ async function waitForFirstLoadToComplete() { describe("FEATURE: Audit Messages Query State", () => { beforeEach(() => { vi.clearAllMocks(); + auditingStatus.value = "Available"; localStorage.clear(); }); @@ -310,6 +312,17 @@ describe("FEATURE: Audit Messages Query State", () => { }); }); + describe("RULE: Onboarding prompts render only after the capability probe has answered", () => { + test("EXAMPLE: The banner appears once a completed probe found no messages", async () => { + auditingStatus.value = "Endpoints Not Configured"; + await renderAuditList([]); + + await waitForFirstLoadToComplete(); + + expect(document.querySelector("page-banner-stub")).not.toBeNull(); + }); + }); + 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([]); diff --git a/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts b/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts index 307f996b6d..a830760bf0 100644 --- a/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts +++ b/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts @@ -149,8 +149,8 @@ export function useAuditingCapability(): CapabilityComposable { const auditIndicators = computed(() => { const indicators: StatusIndicator[] = []; - // Messages available indicator - show if at least one instance is available - if (hasAvailableAuditInstances(auditInstances.value)) { + // Messages available indicator - show if at least one instance is available and the probe answered + if (hasAvailableAuditInstances(auditInstances.value) && hasSuccessfulMessages.value !== null) { const messagesAvailable = isAllMessagesSupported.value && hasSuccessfulMessages.value; let messageTooltip: string; diff --git a/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts b/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts index b2a9e11433..f8babf93a4 100644 --- a/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts +++ b/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts @@ -18,6 +18,34 @@ describe("FEATURE: Successful-messages capability probe", () => { localStorage.clear(); }); + test("EXAMPLE: Before the first probe answers, the state is unknown, not 'no messages'", () => { + const store = usePlatformCapabilitiesStore(); + + expect(store.hasSuccessfulMessages).toBeNull(); + }); + + test("EXAMPLE: A failed probe proves nothing — the state stays unknown and is retried", async () => { + auditClient.hasSuccessfulMessages.mockRejectedValue(new Error("boom")); + const store = usePlatformCapabilitiesStore(); + + await store.refresh(); + expect(store.hasSuccessfulMessages).toBeNull(); + + auditClient.hasSuccessfulMessages.mockReset(); + auditClient.hasSuccessfulMessages.mockResolvedValue(true); + await store.refresh(); + expect(store.hasSuccessfulMessages).toBe(true); + }); + + test("EXAMPLE: A completed probe finding nothing reports false", async () => { + auditClient.hasSuccessfulMessages.mockResolvedValue(false); + const store = usePlatformCapabilitiesStore(); + + await store.refresh(); + + expect(store.hasSuccessfulMessages).toBe(false); + }); + test("EXAMPLE: A bounded window is probed before falling back to the full store", async () => { auditClient.hasSuccessfulMessages.mockResolvedValueOnce(false).mockResolvedValueOnce(true); const store = usePlatformCapabilitiesStore(); diff --git a/src/Frontend/src/stores/PlatformCapabilitiesStore.ts b/src/Frontend/src/stores/PlatformCapabilitiesStore.ts index f3cea6d332..e6297a68f8 100644 --- a/src/Frontend/src/stores/PlatformCapabilitiesStore.ts +++ b/src/Frontend/src/stores/PlatformCapabilitiesStore.ts @@ -57,7 +57,8 @@ function saveVisibility(visibility: PlatformCapabilitiesVisibility): void { export const usePlatformCapabilitiesStore = defineStore("PlatformCapabilitiesStore", () => { const { store: connectionStore } = useConnectionsAndStatsAutoRefresh(); const visibility = ref(loadVisibility()); - const hasSuccessfulMessages = ref(false); + // null = not yet determined: consumers must render nothing promotional until the probe answers + const hasSuccessfulMessages = ref(null); const hasMonitoredEndpoints = ref(false); async function checkForSuccessfulMessages() { @@ -65,14 +66,15 @@ export const usePlatformCapabilitiesStore = defineStore("PlatformCapabilitiesSto // The unbounded form of this probe scans the entire audit index (minutes on large // stores), so probe a recent window first and fall back to unbounded only when // the window is empty. - if (hasSuccessfulMessages.value) { + if (hasSuccessfulMessages.value === true) { return; } try { const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000); hasSuccessfulMessages.value = (await auditClient.hasSuccessfulMessages(sevenDaysAgo)) || (await auditClient.hasSuccessfulMessages()); } catch { - hasSuccessfulMessages.value = false; + // A failed probe proves nothing about the data; stay unknown and let the next tick retry + hasSuccessfulMessages.value = null; } }