mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Page the admin app logs fifty rows at a time
The buffer answers with up to ~500 events and the table painted all of them. It now shows fifty per page with the login overview's pager, resets to the first page when a filter changes, and clamps the page when a refetch returns fewer events.
This commit is contained in:
1 parent
2d79b80298
commit
834803ff21
2 files changed
+134
-6
No files matched your search
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AppLogPreviewSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(/<tr /g)?.length).toBe(APP_LOG_PAGE_SIZE + 1);
|
||||
expect(html).toContain(">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"');
|
||||
});
|
||||
});
|
||||
@@ -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<LogLevel | "__all__">("__all__");
|
||||
const [range, setRange] = useState<RangePreset>("1h");
|
||||
const [selected, setSelected] = useState<WideEvent | null>(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 (
|
||||
<SettingsCard>
|
||||
@@ -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")}
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.section.app-logs.filterAction")}
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setActionFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
aria-label={t("admin.section.app-logs.filterAction")}
|
||||
/>
|
||||
<Select
|
||||
value={level}
|
||||
onValueChange={(v) => setLevel(v as LogLevel | "__all__")}
|
||||
onValueChange={(v) => {
|
||||
setLevel(v as LogLevel | "__all__");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
@@ -165,7 +190,13 @@ export function AppLogPreviewSection() {
|
||||
<SelectItem value="error">error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={range} onValueChange={(v) => setRange(v as RangePreset)}>
|
||||
<Select
|
||||
value={range}
|
||||
onValueChange={(v) => {
|
||||
setRange(v as RangePreset);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-label={t("admin.section.app-logs.filterRange")}
|
||||
@@ -246,7 +277,7 @@ export function AppLogPreviewSection() {
|
||||
data-slot="app-log-rows"
|
||||
className="divide-border divide-y"
|
||||
>
|
||||
{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() {
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground flex items-center justify-between text-xs"
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-between gap-2 text-xs"
|
||||
data-testid="app-log-preview-summary"
|
||||
>
|
||||
<span>
|
||||
@@ -299,6 +330,38 @@ export function AppLogPreviewSection() {
|
||||
bufferMax: data?.meta.bufferMax ?? 500,
|
||||
})}
|
||||
</span>
|
||||
{/* Same pager as the login overview. */}
|
||||
{lastPage > 1 ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
data-testid="app-log-pagination"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage(Math.max(1, currentPage - 1))}
|
||||
>
|
||||
{t("admin.section.auditLog.prev")}
|
||||
</Button>
|
||||
<span>
|
||||
{t("admin.section.auditLog.pageOf", {
|
||||
page: currentPage,
|
||||
total: lastPage,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={currentPage >= lastPage}
|
||||
onClick={() => setPage(Math.min(lastPage, currentPage + 1))}
|
||||
>
|
||||
{t("admin.section.auditLog.next")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user