diff --git a/src/Frontend/package-lock.json b/src/Frontend/package-lock.json index daee56d27d..cda79406bb 100644 --- a/src/Frontend/package-lock.json +++ b/src/Frontend/package-lock.json @@ -20,7 +20,6 @@ "@tinyhttp/content-disposition": "2.2.4", "@vue-flow/controls": "1.1.3", "@vue-flow/core": "1.48.2", - "@vuepic/vue-datepicker": "13.0.0", "@vueuse/core": "14.3.0", "bootstrap": "5.3.8", "codemirror": "6.0.2", @@ -1009,12 +1008,6 @@ "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", "license": "MIT" }, - "node_modules/@date-fns/tz": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", - "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", - "license": "MIT" - }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -1168,42 +1161,6 @@ } } }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" - }, - "node_modules/@floating-ui/vue": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz", - "integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.6", - "@floating-ui/utils": "^0.2.11", - "vue-demi": ">=0.13.0" - } - }, "node_modules/@fortawesome/fontawesome-common-types": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.2.0.tgz", @@ -1617,6 +1574,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1633,6 +1591,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1649,6 +1608,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1665,6 +1625,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1681,6 +1642,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1697,9 +1659,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1716,9 +1676,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1735,9 +1693,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1754,9 +1710,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1773,9 +1727,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1792,9 +1744,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1811,6 +1761,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1827,6 +1778,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1843,6 +1795,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2080,7 +2033,7 @@ "version": "25.9.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -2946,24 +2899,6 @@ } } }, - "node_modules/@vuepic/vue-datepicker": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-13.0.0.tgz", - "integrity": "sha512-7emyDtXcTDn0w1+RLcyYhdZFw2pE9EGbDuyFrN3XJju8nJVPcU822M2znrzmNdZU82R5ItyBeagZBtsYzUVy3g==", - "license": "MIT", - "dependencies": { - "@date-fns/tz": "^1.4.1", - "@floating-ui/vue": "^1.1.11", - "@vueuse/core": "^14.3.0", - "date-fns": "^4.2.1" - }, - "engines": { - "node": ">=18.12.0" - }, - "peerDependencies": { - "vue": ">=3.5.0" - } - }, "node_modules/@vueuse/core": { "version": "14.3.0", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", @@ -3847,16 +3782,6 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/date-fns": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.3.0.tgz", - "integrity": "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", @@ -4624,6 +4549,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5592,6 +5518,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5612,6 +5539,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5632,6 +5560,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5652,6 +5581,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5672,6 +5602,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5692,9 +5623,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5715,9 +5644,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5738,9 +5665,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5761,9 +5686,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5784,6 +5707,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5804,6 +5728,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7407,7 +7332,7 @@ "version": "7.24.4", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.4.tgz", "integrity": "sha512-cRaY9PagdEZoRmcwzk3tUV3SVGrVQkR6bcSilav/A0vXsfpW4Lvd0BvgRMwTEDTLLGN+QdyBTG+nnvTgJhdt6w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { diff --git a/src/Frontend/package.json b/src/Frontend/package.json index 652873f3c6..32133c3b7d 100644 --- a/src/Frontend/package.json +++ b/src/Frontend/package.json @@ -32,7 +32,6 @@ "@tinyhttp/content-disposition": "2.2.4", "@vue-flow/controls": "1.1.3", "@vue-flow/core": "1.48.2", - "@vuepic/vue-datepicker": "13.0.0", "@vueuse/core": "14.3.0", "bootstrap": "5.3.8", "codemirror": "6.0.2", diff --git a/src/Frontend/src/components/audit/AuditList.spec.ts b/src/Frontend/src/components/audit/AuditList.spec.ts index 14a4d2cb59..ee80387842 100644 --- a/src/Frontend/src/components/audit/AuditList.spec.ts +++ b/src/Frontend/src/components/audit/AuditList.spec.ts @@ -1,5 +1,5 @@ import { describe, test, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/vue"; +import { fireEvent, render, screen, waitFor } from "@testing-library/vue"; import { createTestingPinia } from "@pinia/testing"; import { createRouter, createMemoryHistory } from "vue-router"; import { ref, shallowReadonly, nextTick, type Ref } from "vue"; @@ -209,6 +209,7 @@ async function waitForFirstLoadToComplete() { describe("FEATURE: Audit Messages Query State", () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); }); describe("RULE: A spinner is shown during the initial page load", () => { @@ -333,6 +334,45 @@ describe("FEATURE: Audit Messages Query State", () => { expect(screen.queryByTestId("query-error")).not.toBeInTheDocument(); }); + test("EXAMPLE: A failed query offers one-click narrower ranges", async () => { + const { store } = await renderAuditList([]); + + await waitForFirstLoadToComplete(); + + // default range is now-6h -> now; the two next-narrower presets apply + store.queryFailed = true; + await nextTick(); + + expect(screen.getAllByTestId("narrow-range").map((b) => b.textContent)).toEqual(["Last hour", "Last 15 minutes"]); + }); + + test("EXAMPLE: Clicking a narrowing action applies that range", async () => { + const { store } = await renderAuditList([]); + + await waitForFirstLoadToComplete(); + + store.queryFailed = true; + await nextTick(); + await fireEvent.click(screen.getByText("Last hour")); + + expect(store.timeRangeFrom).toBe("now-1h"); + expect(store.timeRangeTo).toBe("now"); + }); + + test("EXAMPLE: A failed query without a time filter says the scan was unbounded", async () => { + const { store } = await renderAuditList([]); + + await waitForFirstLoadToComplete(); + + store.timeRangeFrom = ""; + store.timeRangeTo = ""; + store.queryFailed = true; + await nextTick(); + + expect(screen.getByText(/no time filter/)).toBeInTheDocument(); + expect(screen.getAllByTestId("narrow-range").map((b) => b.textContent)).toEqual(["Last 7 days", "Last 24 hours"]); + }); + test("EXAMPLE: The error banner is not shown when queries succeed", async () => { const { verify } = await renderAuditList([createMessage()]); @@ -382,6 +422,19 @@ describe("FEATURE: Audit Messages Query State", () => { }); }); + describe("RULE: The saved default range drives the first query", () => { + test("EXAMPLE: Opening the view without URL params applies the browser's saved default", async () => { + localStorage.setItem("audit.defaultTimeRange", JSON.stringify({ from: "now-24h", to: "now" })); + + const { store, refreshNow } = await renderAuditList([]); + await waitForFirstLoadToComplete(); + + expect(store.timeRangeFrom).toBe("now-24h"); + expect(store.timeRangeTo).toBe("now"); + expect(refreshNow).toHaveBeenCalled(); + }); + }); + 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 }); diff --git a/src/Frontend/src/components/audit/AuditList.vue b/src/Frontend/src/components/audit/AuditList.vue index 053cd8aebd..6ee6faba93 100644 --- a/src/Frontend/src/components/audit/AuditList.vue +++ b/src/Frontend/src/components/audit/AuditList.vue @@ -16,9 +16,10 @@ import { useAuditingCapability } from "@/components/platformcapabilities/capabil import { CapabilityStatus } from "@/components/platformcapabilities/constants"; import PageBanner, { type BannerMessage } from "@/components/PageBanner.vue"; import { useConfigurationStore } from "@/stores/ConfigurationStore"; +import { loadDefaultRange, narrowingPresets, resolveTimeRange, type RangePreset } from "@/components/audit/timeRange"; const store = useAuditStore(); -const { messages, totalCount, sortBy, messageFilterString, selectedEndpointName, itemsPerPage, dateRange, queryFailed } = storeToRefs(store); +const { messages, totalCount, sortBy, messageFilterString, selectedEndpointName, itemsPerPage, timeRangeFrom, timeRangeTo, queryFailed } = storeToRefs(store); const route = useRoute(); const router = useRouter(); const autoRefreshValue = ref(null); @@ -60,6 +61,16 @@ const bannerMessage = computed(() => { const showBannerAction = computed(() => auditStatus.value !== CapabilityStatus.Unavailable && auditStatus.value !== CapabilityStatus.PartiallyUnavailable); +// Query cost grows with the time window, so a timed-out query's escape hatch +// is a narrower one — offered as one click instead of prose alone +const hasNoTimeFilter = computed(() => resolveTimeRange({ from: timeRangeFrom.value, to: timeRangeTo.value }) === null); +const narrowOptions = computed(() => (queryFailed.value ? narrowingPresets({ from: timeRangeFrom.value, to: timeRangeTo.value }) : [])); + +function applyNarrowing(preset: RangePreset) { + timeRangeFrom.value = preset.from; + timeRangeTo.value = preset.to; +} + onBeforeMount(() => { setQuery(); @@ -95,15 +106,13 @@ 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() ?? "", + from: timeRangeFrom.value.trim(), + to: timeRangeTo.value.trim(), pageSize: itemsPerPage.value, }; } @@ -114,7 +123,7 @@ function controlsQuery() { // query is still running, so a user typing a search is never ignored. let lastAppliedControlsQuery = ""; -const watchHandle = watch([itemsPerPage, sortBy, messageFilterString, selectedEndpointName, dateRange], async () => { +const watchHandle = watch([itemsPerPage, sortBy, messageFilterString, selectedEndpointName, timeRangeFrom, timeRangeTo], async () => { const query = controlsQuery(); const serialized = JSON.stringify(query); @@ -137,7 +146,15 @@ function setQuery() { ? { isAscending: query.sortDir === "asc", property: query.sortBy as string } : (sortBy.value = { isAscending: false, property: FieldNames.TimeSent }); itemsPerPage.value = query.pageSize ? parseInt(query.pageSize as string) : 100; - dateRange.value = query.from && query.to ? [new Date(query.from as string), new Date(query.to as string)] : []; + if (query.from !== undefined || query.to !== undefined) { + timeRangeFrom.value = (query.from as string) ?? ""; + timeRangeTo.value = (query.to as string) ?? ""; + } else { + // No range in the URL: the user's saved default (factory: last 6 hours) + const defaultRange = loadDefaultRange(); + timeRangeFrom.value = defaultRange.from; + timeRangeTo.value = defaultRange.to; + } selectedEndpointName.value = (query.endpoint ?? "") as string; lastAppliedControlsQuery = JSON.stringify(controlsQuery()); @@ -173,7 +190,11 @@ watch(autoRefreshValue, (newValue) => {