From cf8e429f84a2e515e4d3a5e0f11dd1a96f916738 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 3 Sep 2026 17:28:24 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Super=20date=20picker=20for=20A?= =?UTF-8?q?ll=20Messages=20with=20a=20configurable=20bounded=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the calendar-only date range control with a compact chip in the query bar, "Sent: Last 6 hours", labeled for what it filters (time_sent) and styled like the neighboring dropdowns. The chip shows the preset name, the live expression ("now-90m"; a range ending now is what "last N" means) or "start to end" for an absolute range; the resolved window in UTC and local time is its tooltip. The editor is laid out like Grafana's: the absolute range on the left with From and To on their own rows, the quick ranges listed on the right. Each bound accepts relative expressions that stay live (now, now-6h, now/d, now-1d/d), tolerant RFC 3339 (T or space separator, seconds optional, zone optional; zone-less is local, Z forces UTC, explicit offsets are honored) and pasted ISO 8601 intervals split across both bounds; a hint under the fields says so. A calendar button per bound opens the browser's own date picker (day only, so it closes on the day click) and writes the day into the field while keeping the time the field already says. A timed-out query offers the two next-narrower presets as one-click buttons (query cost grows with the window), and a query with no time filter at all is called out as an unbounded scan of the whole store. A per-browser default decides what the view opens on; the factory default is the last 6 hours, so opening the view never scans the whole audit store. Ranges travel in the URL as expressions, so shared links re-evaluate and live ranges slide with auto-refresh. The popup is not a Popover API popover: a popover sits in the top layer, centred, and anchoring it under the chip needs CSS anchor positioning, which Firefox ESR does not have yet. --- .../src/components/audit/AuditList.spec.ts | 55 +- .../src/components/audit/AuditList.vue | 58 ++- .../src/components/audit/FiltersPanel.vue | 16 +- .../components/audit/SuperDatePicker.spec.ts | 231 +++++++++ .../src/components/audit/SuperDatePicker.vue | 470 ++++++++++++++++++ .../src/components/audit/timeRange.spec.ts | 176 +++++++ .../src/components/audit/timeRange.ts | 253 ++++++++++ src/Frontend/src/stores/AuditStore.ts | 15 +- 8 files changed, 1257 insertions(+), 17 deletions(-) create mode 100644 src/Frontend/src/components/audit/SuperDatePicker.spec.ts create mode 100644 src/Frontend/src/components/audit/SuperDatePicker.vue create mode 100644 src/Frontend/src/components/audit/timeRange.spec.ts create mode 100644 src/Frontend/src/components/audit/timeRange.ts 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) => {