diff --git a/src/Frontend/src/components/audit/AuditList.spec.ts b/src/Frontend/src/components/audit/AuditList.spec.ts index ee8038784..ea2824fd5 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/audit/auditClient.ts b/src/Frontend/src/components/audit/auditClient.ts index 92baf9d69..feb12cf9e 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/components/platformcapabilities/capabilities/AuditingCapability.ts b/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts index 307f996b6..a830760bf 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/AuditStore.spec.ts b/src/Frontend/src/stores/AuditStore.spec.ts index d121db502..0d75d6144 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 87511f9ab..39980348e 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 000000000..f8babf93a --- /dev/null +++ b/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts @@ -0,0 +1,81 @@ +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: 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(); + + 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 b7a275c12..e6297a68f 100644 --- a/src/Frontend/src/stores/PlatformCapabilitiesStore.ts +++ b/src/Frontend/src/stores/PlatformCapabilitiesStore.ts @@ -57,14 +57,24 @@ 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() { + // 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 === true) { + 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; + // A failed probe proves nothing about the data; stay unknown and let the next tick retry + hasSuccessfulMessages.value = null; } }