diff --git a/src/components/admin/__tests__/app-log-pagination.test.tsx b/src/components/admin/__tests__/app-log-pagination.test.tsx new file mode 100644 index 000000000..23534725e --- /dev/null +++ b/src/components/admin/__tests__/app-log-pagination.test.tsx @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * `/admin/app-logs` pages the buffered events fifty at a time, like the + * login overview, instead of painting up to ~500 rows in one table. + */ + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/app-logs", +})); + +const events = Array.from({ length: 120 }, (_, i) => ({ + request_id: `req-${i}`, + trace_id: `trace${String(i).padStart(11, "0")}`, + level: "info" as const, + timestamp: "2026-05-10T09:00:00Z", + duration_ms: 12, + action: { name: `event.number.${i}` }, + kind: "request", +})); + +let served = events; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { events: served, meta: { total: served.length, bufferMax: 500 } }, + isLoading: false, + refetch: vi.fn(), + isFetching: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + APP_LOG_PAGE_SIZE, + AppLogPreviewSection, +} from "../app-log-preview-section"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("AppLogPreviewSection — pagination", () => { + it("paints one page of fifty rows and a pager", () => { + served = events; + const html = render(); + expect(APP_LOG_PAGE_SIZE).toBe(50); + expect(html.match(/event.number.0<"); + expect(html).not.toContain(">event.number.50<"); + expect(html).toContain('data-testid="app-log-pagination"'); + expect(html).toContain("Page 1 of 3"); + }); + + it("shows no pager when everything fits on one page", () => { + served = events.slice(0, 10); + expect(render()).not.toContain('data-testid="app-log-pagination"'); + }); +}); diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index 8f23a5bc4..b043500c3 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -55,6 +55,13 @@ import { apiGet } from "@/lib/api/api-fetch"; type RangePreset = "15m" | "1h" | "6h" | "all"; +/** + * Rows per page. The buffer answers with up to ~500 events at once; fifty a + * page matches the login overview's default and keeps the table one screen + * of scrolling instead of ten. + */ +export const APP_LOG_PAGE_SIZE = 50; + interface AppLogsResponse { events: WideEvent[]; meta: { @@ -92,6 +99,7 @@ export function AppLogPreviewSection() { const [level, setLevel] = useState("__all__"); const [range, setRange] = useState("1h"); const [selected, setSelected] = useState(null); + const [page, setPage] = useState(1); const params = useMemo(() => { const p = new URLSearchParams(); @@ -114,6 +122,14 @@ export function AppLogPreviewSection() { }); const events = data?.events ?? []; + // The buffer refetches every 30 s and can shrink under a narrower filter, + // so the page is clamped on read rather than trusted. + const lastPage = Math.max(1, Math.ceil(events.length / APP_LOG_PAGE_SIZE)); + const currentPage = Math.min(page, lastPage); + const pageEvents = events.slice( + (currentPage - 1) * APP_LOG_PAGE_SIZE, + currentPage * APP_LOG_PAGE_SIZE, + ); return ( @@ -133,19 +149,28 @@ export function AppLogPreviewSection() { type="search" placeholder={t("admin.section.app-logs.filterTraceId")} value={traceId} - onChange={(e) => setTraceId(e.target.value)} + onChange={(e) => { + setTraceId(e.target.value); + setPage(1); + }} aria-label={t("admin.section.app-logs.filterTraceId")} /> setActionFilter(e.target.value)} + onChange={(e) => { + setActionFilter(e.target.value); + setPage(1); + }} aria-label={t("admin.section.app-logs.filterAction")} /> - { + setRange(v as RangePreset); + setPage(1); + }} + > - {events.map((event, i) => { + {pageEvents.map((event, i) => { const actionLabel = event.action?.name ?? (`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() || @@ -290,7 +321,7 @@ export function AppLogPreviewSection() {
@@ -299,6 +330,38 @@ export function AppLogPreviewSection() { bufferMax: data?.meta.bufferMax ?? 500, })} + {/* Same pager as the login overview. */} + {lastPage > 1 ? ( +
+ + + {t("admin.section.auditLog.pageOf", { + page: currentPage, + total: lastPage, + })} + + +
+ ) : null}
)}