diff --git a/src/Frontend/src/components/audit/FiltersPanel.vue b/src/Frontend/src/components/audit/FiltersPanel.vue index 19d97892b..d716ef1f4 100644 --- a/src/Frontend/src/components/audit/FiltersPanel.vue +++ b/src/Frontend/src/components/audit/FiltersPanel.vue @@ -5,6 +5,7 @@ import { useAuditStore } from "@/stores/AuditStore"; import ListFilterSelector from "@/components/audit/ListFilterSelector.vue"; import { computed } from "vue"; import SuperDatePicker from "@/components/audit/SuperDatePicker.vue"; +import SearchHistory from "@/components/audit/SearchHistory.vue"; const store = useAuditStore(); const { messageFilterString, selectedEndpointName, endpoints } = storeToRefs(store); @@ -18,7 +19,9 @@ const endpointNames = computed(() => {
- + + +
Check the documentation to see the available filtering options
diff --git a/src/Frontend/src/components/audit/SearchHistory.spec.ts b/src/Frontend/src/components/audit/SearchHistory.spec.ts new file mode 100644 index 000000000..2ae1d02d0 --- /dev/null +++ b/src/Frontend/src/components/audit/SearchHistory.spec.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/vue"; +import { nextTick } from "vue"; +import { createTestingPinia } from "@pinia/testing"; +import SearchHistory from "@/components/audit/SearchHistory.vue"; +import { useAuditStore } from "@/stores/AuditStore"; + +function renderHistory(entries: { search: string; endpoint: string; from?: string; to?: string; at: string }[]) { + const pinia = createTestingPinia({ createSpy: vi.fn, initialState: { AuditStore: { searchHistory: entries } } }); + // The history wraps the search field it belongs to + render(SearchHistory, { global: { plugins: [pinia] }, slots: { default: '' } }); + return useAuditStore(pinia); +} + +const searchField = () => screen.getByLabelText("Search messages") as HTMLInputElement; +const openHistory = () => fireEvent.focusIn(searchField()); +const panel = () => screen.queryByRole("listbox", { name: "Recent searches" }); + +describe("FEATURE: Search history panel under the search field", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("EXAMPLE: Entries list the search, the endpoint and when they ran", async () => { + renderHistory([{ search: "orders", endpoint: "Sales.Endpoint", at: new Date().toISOString() }]); + + await openHistory(); + + expect(screen.getByText("orders")).toBeInTheDocument(); + expect(screen.getByText("@ Sales.Endpoint")).toBeInTheDocument(); + }); + + test("EXAMPLE: Entries show the time range they ran with, worded like the picker", async () => { + renderHistory([ + { search: "orders", endpoint: "", from: "now-1h", to: "now", at: new Date().toISOString() }, + { search: "invoices", endpoint: "", from: "2026-09-01 08:00", to: "2026-09-01 12:00", at: new Date().toISOString() }, + ]); + + await openHistory(); + + expect(screen.getByText("now-1h")).toBeInTheDocument(); + expect(screen.getByText("2026-09-01 08:00 to 2026-09-01 12:00")).toBeInTheDocument(); + }); + + test("EXAMPLE: Clicking an entry reruns it, time range included", async () => { + const store = renderHistory([{ search: "orders", endpoint: "Sales.Endpoint", from: "now-1h", to: "now", at: new Date().toISOString() }]); + + await openHistory(); + await fireEvent.click(screen.getByText("orders")); + + expect(store.messageFilterString).toBe("orders"); + expect(store.selectedEndpointName).toBe("Sales.Endpoint"); + expect(store.timeRangeFrom).toBe("now-1h"); + expect(store.timeRangeTo).toBe("now"); + }); + + test("EXAMPLE: Rerunning an entry recorded before ranges were captured leaves the current range alone", async () => { + const store = renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + store.timeRangeFrom = "now-6h"; + store.timeRangeTo = "now"; + + await openHistory(); + await fireEvent.click(screen.getByText("orders")); + + expect(store.timeRangeFrom).toBe("now-6h"); + expect(store.timeRangeTo).toBe("now"); + }); + + test("EXAMPLE: The panel opens when the search field receives focus and closes on Escape", async () => { + renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + + expect(panel()).not.toBeInTheDocument(); + + await openHistory(); + expect(panel()).toBeInTheDocument(); + + await fireEvent.keyDown(searchField(), { key: "Escape" }); + expect(panel()).not.toBeInTheDocument(); + }); + + test("EXAMPLE: Clicking the search field re-opens the panel after it was dismissed", async () => { + renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + + await openHistory(); + await fireEvent.keyDown(searchField(), { key: "Escape" }); + await fireEvent.click(searchField()); + + expect(panel()).toBeInTheDocument(); + }); + + test("EXAMPLE: Typing narrows the panel to the searches that match", async () => { + renderHistory([ + { search: "orders", endpoint: "", at: new Date().toISOString() }, + { search: "invoices", endpoint: "Billing", at: new Date().toISOString() }, + ]); + + await openHistory(); + await fireEvent.input(searchField(), { target: { value: "inv" } }); + + expect(screen.queryByText("orders")).not.toBeInTheDocument(); + expect(screen.getByText("invoices")).toBeInTheDocument(); + + await fireEvent.input(searchField(), { target: { value: "bill" } }); + expect(screen.getByText("invoices")).toBeInTheDocument(); + + await fireEvent.input(searchField(), { target: { value: "zzz" } }); + expect(panel()).not.toBeInTheDocument(); + }); + + test("EXAMPLE: An empty history shows no panel, even on focus", async () => { + renderHistory([]); + + await openHistory(); + + expect(panel()).not.toBeInTheDocument(); + }); + + test("EXAMPLE: Clear history goes through the store", async () => { + const store = renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + + await openHistory(); + await fireEvent.click(screen.getByText("Clear history")); + + expect(store.clearSearchHistory).toHaveBeenCalled(); + }); + + describe("RULE: The panel closes once the search is submitted, and typing brings it back", () => { + test("EXAMPLE: The search reaching the store closes the panel", async () => { + const store = renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + await openHistory(); + expect(panel()).toBeInTheDocument(); + + // what the debounced search field does once the user pauses + store.messageFilterString = "ord"; + await nextTick(); + + expect(panel()).not.toBeInTheDocument(); + }); + + test("EXAMPLE: Typing again after that reopens the panel", async () => { + const store = renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + await openHistory(); + store.messageFilterString = "ord"; + await nextTick(); + + await fireEvent.input(searchField(), { target: { value: "orde" } }); + + expect(panel()).toBeInTheDocument(); + }); + + test("EXAMPLE: Enter submits the search and closes the panel", async () => { + renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + await openHistory(); + + await fireEvent.keyDown(searchField(), { key: "Enter" }); + + expect(panel()).not.toBeInTheDocument(); + }); + }); + + describe("RULE: The panel is a combobox: arrows move, Enter picks, Tab and blur leave", () => { + const entries = () => [ + { search: "orders", endpoint: "", at: new Date().toISOString() }, + { search: "invoices", endpoint: "Billing", at: new Date().toISOString() }, + ]; + + test("EXAMPLE: Entries are not tab stops, and Tab closes the panel", async () => { + renderHistory(entries()); + await openHistory(); + + const options = screen.getAllByRole("option"); + expect(options.every((option) => option.getAttribute("tabindex") === "-1")).toBe(true); + + await fireEvent.keyDown(searchField(), { key: "Tab" }); + expect(panel()).not.toBeInTheDocument(); + }); + + test("EXAMPLE: Arrow keys move a highlight and Enter reruns the highlighted entry", async () => { + const store = renderHistory(entries()); + await openHistory(); + + await fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + await fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + expect(screen.getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true"); + + await fireEvent.keyDown(searchField(), { key: "Enter" }); + + expect(store.messageFilterString).toBe("invoices"); + expect(store.selectedEndpointName).toBe("Billing"); + expect(panel()).not.toBeInTheDocument(); + }); + + test("EXAMPLE: Arrow Down opens a closed panel", async () => { + renderHistory(entries()); + await openHistory(); + await fireEvent.keyDown(searchField(), { key: "Escape" }); + expect(panel()).not.toBeInTheDocument(); + + await fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + + expect(panel()).toBeInTheDocument(); + }); + + test("EXAMPLE: The field announces itself as a combobox that controls the list", async () => { + renderHistory(entries()); + await openHistory(); + + const field = searchField(); + expect(field).toHaveAttribute("role", "combobox"); + expect(field).toHaveAttribute("aria-expanded", "true"); + expect(field.getAttribute("aria-controls")).toBe(panel()!.id); + + await fireEvent.keyDown(field, { key: "ArrowDown" }); + expect(field.getAttribute("aria-activedescendant")).toBe(screen.getAllByRole("option")[0].id); + }); + + test("EXAMPLE: Focus leaving the field and the panel closes it", async () => { + renderHistory(entries()); + const outside = document.createElement("button"); + document.body.appendChild(outside); + await openHistory(); + + await fireEvent.focusOut(searchField(), { relatedTarget: outside }); + + expect(panel()).not.toBeInTheDocument(); + outside.remove(); + }); + }); +}); diff --git a/src/Frontend/src/components/audit/SearchHistory.vue b/src/Frontend/src/components/audit/SearchHistory.vue new file mode 100644 index 000000000..62648675d --- /dev/null +++ b/src/Frontend/src/components/audit/SearchHistory.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/src/Frontend/src/components/audit/searchHistory.ts b/src/Frontend/src/components/audit/searchHistory.ts new file mode 100644 index 000000000..9c53224af --- /dev/null +++ b/src/Frontend/src/components/audit/searchHistory.ts @@ -0,0 +1,67 @@ +// Per-browser history of audit searches (search text and/or endpoint), most +// recently used first. Re-running or re-entering an existing search bumps it to +// the front instead of duplicating it; the least recently used entry falls off +// once the list is full. + +export interface SearchHistoryEntry { + search: string; + endpoint: string; + // Time-range expressions the search ran with ("" on both = no time filter). + // Absent on entries recorded before ranges were captured; rerunning those + // leaves the current range untouched. + from?: string; + to?: string; + at: string; // ISO timestamp of last use +} + +const STORAGE_KEY = "audit.searchHistory"; +export const searchHistoryLimit = 10; + +export function loadSearchHistory(): SearchHistoryEntry[] { + try { + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? ""); + if (Array.isArray(stored)) { + const optionalString = (v: unknown) => v === undefined || typeof v === "string"; + return stored.filter((e) => e && typeof e.search === "string" && typeof e.endpoint === "string" && typeof e.at === "string" && optionalString(e.from) && optionalString(e.to)); + } + } catch { + // fall through to empty history + } + return []; +} + +function save(entries: SearchHistoryEntry[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + } catch { + // storage unavailable: history just doesn't persist + } +} + +// Records a use of (search, endpoint, time range) and returns the updated +// history. The range is part of a search's identity: the same text over a +// different window is a different query. +export function recordSearch(search: string, endpoint: string, range: { from: string; to: string }, now: () => Date = () => new Date()): SearchHistoryEntry[] { + const trimmedSearch = search.trim(); + const trimmedEndpoint = endpoint.trim(); + if (trimmedSearch === "" && trimmedEndpoint === "") { + return loadSearchHistory(); + } + + const from = range.from.trim(); + const to = range.to.trim(); + const entries = loadSearchHistory().filter((e) => !(e.search === trimmedSearch && e.endpoint === trimmedEndpoint && e.from === from && e.to === to)); + entries.unshift({ search: trimmedSearch, endpoint: trimmedEndpoint, from, to, at: now().toISOString() }); + const capped = entries.slice(0, searchHistoryLimit); + save(capped); + return capped; +} + +export function clearSearchHistory(): SearchHistoryEntry[] { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } + return []; +} diff --git a/src/Frontend/src/components/audit/searchHistoryStorage.spec.ts b/src/Frontend/src/components/audit/searchHistoryStorage.spec.ts new file mode 100644 index 000000000..2fd033d60 --- /dev/null +++ b/src/Frontend/src/components/audit/searchHistoryStorage.spec.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { clearSearchHistory, loadSearchHistory, recordSearch, searchHistoryLimit } from "@/components/audit/searchHistory"; + +const at = (iso: string) => () => new Date(iso); +const last6h = { from: "now-6h", to: "now" }; +const lastHour = { from: "now-1h", to: "now" }; + +describe("FEATURE: Audit search history", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("EXAMPLE: The most recent search is listed first", () => { + recordSearch("orders", "", last6h, at("2026-09-03T10:00:00Z")); + recordSearch("", "Sales.Endpoint", last6h, at("2026-09-03T11:00:00Z")); + + const history = loadSearchHistory(); + expect(history.map((e) => e.search)).toEqual(["", "orders"]); + expect(history[0].endpoint).toBe("Sales.Endpoint"); + }); + + test("EXAMPLE: The time range the search ran with is captured", () => { + recordSearch("orders", "", lastHour, at("2026-09-03T10:00:00Z")); + + expect(loadSearchHistory()[0]).toMatchObject({ search: "orders", from: "now-1h", to: "now" }); + }); + + test("EXAMPLE: Re-running a search bumps it to the front instead of duplicating", () => { + recordSearch("orders", "", last6h, at("2026-09-03T10:00:00Z")); + recordSearch("invoices", "", last6h, at("2026-09-03T11:00:00Z")); + const history = recordSearch("orders", "", last6h, at("2026-09-03T12:00:00Z")); + + expect(history).toHaveLength(2); + expect(history[0]).toMatchObject({ search: "orders", at: "2026-09-03T12:00:00.000Z" }); + }); + + test("EXAMPLE: The same text on a different endpoint is a different search", () => { + recordSearch("orders", "A", last6h, at("2026-09-03T10:00:00Z")); + const history = recordSearch("orders", "B", last6h, at("2026-09-03T11:00:00Z")); + + expect(history).toHaveLength(2); + }); + + test("EXAMPLE: The same text over a different time range is a different search", () => { + recordSearch("orders", "", last6h, at("2026-09-03T10:00:00Z")); + const history = recordSearch("orders", "", lastHour, at("2026-09-03T11:00:00Z")); + + expect(history).toHaveLength(2); + expect(history[0].from).toBe("now-1h"); + }); + + test("EXAMPLE: Entries recorded before ranges were captured still load", () => { + localStorage.setItem("audit.searchHistory", JSON.stringify([{ search: "orders", endpoint: "", at: "2026-09-03T10:00:00.000Z" }])); + + const history = loadSearchHistory(); + expect(history).toHaveLength(1); + expect(history[0].from).toBeUndefined(); + }); + + test("EXAMPLE: The least recently used entry falls off when the list is full", () => { + for (let i = 0; i < searchHistoryLimit + 1; i++) { + recordSearch(`term-${i}`, "", last6h, at(`2026-09-03T10:${String(i).padStart(2, "0")}:00Z`)); + } + + const history = loadSearchHistory(); + expect(history).toHaveLength(searchHistoryLimit); + expect(history.some((e) => e.search === "term-0")).toBe(false); + expect(history[0].search).toBe(`term-${searchHistoryLimit}`); + }); + + test("EXAMPLE: Queries without search text or endpoint are not recorded", () => { + recordSearch(" ", "", last6h, at("2026-09-03T10:00:00Z")); + + expect(loadSearchHistory()).toHaveLength(0); + }); + + test("EXAMPLE: Corrupt storage yields an empty history", () => { + localStorage.setItem("audit.searchHistory", "{nonsense"); + + expect(loadSearchHistory()).toEqual([]); + }); + + test("EXAMPLE: Clearing empties the history", () => { + recordSearch("orders", "", last6h, at("2026-09-03T10:00:00Z")); + + expect(clearSearchHistory()).toEqual([]); + expect(loadSearchHistory()).toEqual([]); + }); +}); diff --git a/src/Frontend/src/stores/AuditStore.spec.ts b/src/Frontend/src/stores/AuditStore.spec.ts index 4cccb8d3f..dad1797a7 100644 --- a/src/Frontend/src/stores/AuditStore.spec.ts +++ b/src/Frontend/src/stores/AuditStore.spec.ts @@ -118,6 +118,21 @@ describe("AuditStore refresh", () => { expect(store.queryDurationMs).toBeGreaterThanOrEqual(0); }); + test("a query with search text or endpoint is recorded in the search history", async () => { + fetchTypedFromServiceControl.mockResolvedValue([responseWithTotalCount(0), []]); + const store = useAuditStore(); + + await store.refresh(); // no search, no endpoint: nothing recorded + expect(store.searchHistory).toHaveLength(0); + + store.messageFilterString = "orders"; + await store.refresh(); + await store.refresh(); // repeat does not duplicate + + expect(store.searchHistory).toHaveLength(1); + expect(store.searchHistory[0]).toMatchObject({ search: "orders", endpoint: "" }); + }); + test("cancelQuery aborts the query in flight without reporting a failure", async () => { const store = useAuditStore(); diff --git a/src/Frontend/src/stores/AuditStore.ts b/src/Frontend/src/stores/AuditStore.ts index e1877774f..b2aad3255 100644 --- a/src/Frontend/src/stores/AuditStore.ts +++ b/src/Frontend/src/stores/AuditStore.ts @@ -7,6 +7,7 @@ import type { DateRange } from "@/types/date"; import serviceControlClient from "@/components/serviceControlClient"; import auditClient from "@/components/audit/auditClient"; import { loadDefaultRange, resolveTimeRange } from "@/components/audit/timeRange"; +import { clearSearchHistory, loadSearchHistory, recordSearch } from "@/components/audit/searchHistory"; export enum FieldNames { TimeSent = "time_sent", @@ -37,6 +38,7 @@ export const useAuditStore = defineStore("AuditStore", () => { // of the query that produced the current results const queryStartedAt = ref(null); const queryDurationMs = ref(null); + const searchHistory = ref(loadSearchHistory()); let activeQuery: AbortController | null = null; async function loadEndpoints() { @@ -63,6 +65,10 @@ export const useAuditStore = defineStore("AuditStore", () => { const started = performance.now(); queryStartedAt.value = Date.now(); + if (messageFilterString.value.trim() !== "" || selectedEndpointName.value.trim() !== "") { + searchHistory.value = recordSearch(messageFilterString.value, selectedEndpointName.value, { from: timeRangeFrom.value, to: timeRangeTo.value }); + } + try { const [response, data] = await auditClient.getMessages( { @@ -107,6 +113,10 @@ export const useAuditStore = defineStore("AuditStore", () => { // 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 clearHistory() { + searchHistory.value = clearSearchHistory(); + } + function cancelQuery() { activeQuery?.abort(); activeQuery = null; @@ -140,6 +150,8 @@ export const useAuditStore = defineStore("AuditStore", () => { queryFailed, queryStartedAt, queryDurationMs, + searchHistory, + clearSearchHistory: clearHistory, }; });