From c5875af8c8222288634fc06bfb1dce8a75b1e0b1 Mon Sep 17 00:00:00 2001 From: m5r Date: Sun, 23 Aug 2026 09:04:18 +0200 Subject: [PATCH] admin: live ring capacity is injectable, eviction test no longer timing-bound --- .../features/activity/LiveActivity.test.tsx | 55 +++++++++++++++++-- admin/src/features/activity/LiveActivity.tsx | 20 +++++-- admin/src/features/activity/useLiveQueries.ts | 26 ++++++--- 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/admin/src/features/activity/LiveActivity.test.tsx b/admin/src/features/activity/LiveActivity.test.tsx index f1412e5..9e117ef 100644 --- a/admin/src/features/activity/LiveActivity.test.tsx +++ b/admin/src/features/activity/LiveActivity.test.tsx @@ -8,7 +8,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClientProvider } from "@tanstack/react-query"; -import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { RouterContextProvider, RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { AuthProvider } from "@/auth/store"; import { createQueryClient } from "@/lib/queryClient"; import { createAppRouter } from "@/routes"; @@ -16,6 +16,17 @@ import type { Client } from "@/lib/types"; import { provenance, queryRow } from "@/features/provenance/provenanceFixture"; import { health } from "@/lib/healthFixture"; import { FakeEventSource } from "./fakeEventSource"; +import LiveActivity from "./LiveActivity"; +import type { ActivitySearch } from "./search"; + +const LIVE_ORIGIN: ActivitySearch = { + mode: "live", + since: undefined, + until: undefined, + domain: undefined, + client: undefined, + blocked: undefined, +}; function client(ip: string, name: string, learnedName: string): Client { return { @@ -327,23 +338,55 @@ test("opening a second row moves the expanded state and the focus with it", asyn expect(document.activeElement).toBe(second); }); -test("an open streamed detail survives the row being evicted from the ring buffer", async () => { - await openLive(); +/** + * The one render that bypasses the route, because the ring capacity is a + * parameter of the component and the route deliberately never passes it: + * evicting a row at the real 500 means pushing 500 frames through React state, + * which proves nothing the fifth frame does not. The real route tree still + * backs the links inside the detail. + */ +function renderLiveWithCapacity(capacity: number) { + const queryClient = createQueryClient(); + const router = createAppRouter(createMemoryHistory({ initialEntries: ["/activity?mode=live"] }), queryClient); + render( + + + + + + + , + ); +} + +test("an open streamed detail survives the row being evicted from the ring buffer", () => { + renderLiveWithCapacity(5); + act(() => sources[0]!.emit("open")); act(() => sources[0]!.emit("query", frame(1000, "evicted.example"))); fireEvent.click(screen.getByRole("button", { name: "evicted.example" })); expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy(); - // 500 more queries: the ring keeps the newest 500, so the selected row is - // gone from the table. The detail is a snapshot, not a lookup into the ring. + // One ringful more: the ring keeps the newest 5, so the selected row is gone + // from the table. The detail is a snapshot, not a lookup into the ring. act(() => { - for (let index = 0; index < 500; index += 1) { + for (let index = 0; index < 5; index += 1) { sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`)); } }); + expect(screen.getAllByRole("row")).toHaveLength(6); expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull(); expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy(); }); +test("the route renders the live ring at its production capacity", async () => { + await openLive(); + act(() => sources[0]!.emit("query", frame(1000, "pinned.example"))); + + expect(screen.getByText(/last 500 kept/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Freeze" })); + expect(screen.getByText(/newest 500 kept/)).toBeTruthy(); +}); + test("an open streamed detail survives Freeze and Resume", async () => { await openLive(); act(() => sources[0]!.emit("query", frame(1000, "held.example"))); diff --git a/admin/src/features/activity/LiveActivity.tsx b/admin/src/features/activity/LiveActivity.tsx index a93c982..36ebddf 100644 --- a/admin/src/features/activity/LiveActivity.tsx +++ b/admin/src/features/activity/LiveActivity.tsx @@ -276,8 +276,19 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi ); } -export default function LiveActivity({ origin }: { origin: ActivitySearch }) { - const live = useLiveQueries(); +/** + * `capacity` is the ring size, a parameter only so a test can provoke an + * eviction with a handful of rows rather than 500 frames through React state. + * The route renders this without it, so the app is always the 500-row ring. + */ +export default function LiveActivity({ + origin, + capacity = RING_CAPACITY, +}: { + origin: ActivitySearch; + capacity?: number; +}) { + const live = useLiveQueries({ capacity }); const clientNames = useClientNames(); const [selected, setSelected] = useState(null); const trigger = useRef(null); @@ -312,8 +323,7 @@ export default function LiveActivity({ origin }: { origin: ActivitySearch }) { {live.frozen && (

- Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "} - kept). + Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {capacity} kept).

)} @@ -413,7 +423,7 @@ export default function LiveActivity({ origin }: { origin: ActivitySearch }) {

Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "} - {RING_CAPACITY} kept). + {capacity} kept).

)} diff --git a/admin/src/features/activity/useLiveQueries.ts b/admin/src/features/activity/useLiveQueries.ts index 06466a2..28a9486 100644 --- a/admin/src/features/activity/useLiveQueries.ts +++ b/admin/src/features/activity/useLiveQueries.ts @@ -24,6 +24,12 @@ export interface LiveQueriesOptions { fetchSince?: (since: number) => Promise; /** Cheap session-gated GET fired once on entering capped, to distinguish an expired session from a real cap. */ probeSession?: () => Promise; + /** + * Ring size. Injectable so a test can provoke an eviction with a handful of + * rows instead of pushing 500 frames through React state; the app never + * passes it, and the operator never sees it. + */ + capacity?: number; } // A transient drop is invisible to EventSource beyond a bare `error` event; @@ -34,7 +40,10 @@ export interface LiveQueriesOptions { export const CAP_ERROR_THRESHOLD = 3; const defaultEventSource: EventSourceFactory = (url) => new EventSource(url); -const defaultFetchSince = (since: number): Promise => api.getQueries({ since, limit: RING_CAPACITY }); +const defaultFetchSince = + (capacity: number) => + (since: number): Promise => + api.getQueries({ since, limit: capacity }); const defaultProbeSession = (): Promise => api.getPause(); function isUnauthorized(error: unknown): boolean { @@ -79,7 +88,8 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries { errorsRef.current = 0; setStatus("connecting"); const opts = optionsRef.current; - const fetchSince = opts?.fetchSince ?? defaultFetchSince; + const capacity = opts?.capacity ?? RING_CAPACITY; + const fetchSince = opts?.fetchSince ?? defaultFetchSince(capacity); const probeSession = opts?.probeSession ?? defaultProbeSession; const es = (opts?.createEventSource ?? defaultEventSource)(opts?.url ?? api.liveQueriesUrl); esRef.current = es; @@ -94,7 +104,7 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries { fetchSince(since).then( (page) => { if (esRef.current !== es) return; - const merged = mergeGap(bufferRef.current, page.queries, () => ++keyRef.current); + const merged = mergeGap(bufferRef.current, page.queries, () => ++keyRef.current, capacity); bufferRef.current = merged.rows; setRows(merged.rows); setMissed(merged.missed); @@ -122,11 +132,11 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries { return; } lastSeenTsRef.current = payload.request.time; - bufferRef.current = pushRow(bufferRef.current, { - kind: "streamed", - event: payload, - key: ++keyRef.current, - }); + bufferRef.current = pushRow( + bufferRef.current, + { kind: "streamed", event: payload, key: ++keyRef.current }, + capacity, + ); setRows(bufferRef.current); });