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")}
/>
-