The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.
Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.
Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The activity page's server-log tab, rendered through the page (which owns the shared filters and
|
|
// the pivots). A request or run id narrows the list to that request or run (and says so, in the
|
|
// filter row and the URL), and Back returns to the view the pivot was made from; an old link to the
|
|
// retired log page opens this tab; the account in use stays pickable; "Live" watches the newest page
|
|
// only, and stops on a failed read instead of re-raising it; a failed read is an error, not an empty
|
|
// log; Copy takes every loaded line, oldest first.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
import type { LogFacets, LogPage, LogQuery, ServerLogRow } from "../lib/api";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({
|
|
t: (key: string, vars?: Record<string, unknown>) =>
|
|
vars?.id != null ? `${key}:${String(vars.id)}` : key,
|
|
i18n: { language: "en" },
|
|
}),
|
|
};
|
|
});
|
|
|
|
const logsMock = vi.fn<(query: LogQuery, before: string | null) => Promise<LogPage>>();
|
|
const facetsMock = vi.fn<(query: LogQuery) => Promise<LogFacets>>();
|
|
vi.mock("../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../lib/api")>();
|
|
return {
|
|
...actual,
|
|
api: {
|
|
...actual.api,
|
|
adminLogs: (query: LogQuery, before: string | null) => logsMock(query, before),
|
|
adminLogFacets: (query: LogQuery) => facetsMock(query),
|
|
adminUsers: () => Promise.resolve([{ id: 1, email: "admin@example.com" }]),
|
|
},
|
|
};
|
|
});
|
|
|
|
const copied: string[] = [];
|
|
vi.mock("../lib/clipboard", () => ({
|
|
copyText: (text: string) => {
|
|
copied.push(text);
|
|
return Promise.resolve(true);
|
|
},
|
|
}));
|
|
|
|
import Activity from "./Activity";
|
|
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
|
|
|
|
// The filter bar lives in the toolbar, which portals into the shell's band: give it one.
|
|
const slot = document.body.appendChild(document.createElement("div"));
|
|
|
|
function row(id: number, extra: Partial<ServerLogRow> = {}): ServerLogRow {
|
|
return {
|
|
id,
|
|
ts: new Date(Date.UTC(2026, 8, 19, 12, 0, id)).toISOString(),
|
|
level: "INFO",
|
|
source: "api",
|
|
logger: "siftlode.http",
|
|
event: "http.request",
|
|
msg: `GET /api/thing/${id} 200`,
|
|
attrs: null,
|
|
exc: null,
|
|
request_id: `req-${String(id).padStart(8, "0")}`,
|
|
user_id: 1,
|
|
job_id: null,
|
|
run_id: null,
|
|
debug_session_id: null,
|
|
version: "0.90.0",
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
const FACETS: LogFacets = {
|
|
levels: [{ value: "INFO", count: 3 }],
|
|
sources: [{ value: "api", count: 3 }],
|
|
events: [{ value: "http.request", count: 3 }],
|
|
users: [{ value: "1", count: 3 }],
|
|
accounts: { "1": "admin@example.com" },
|
|
total: 3,
|
|
};
|
|
|
|
function renderConsole() {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
return render(
|
|
<QueryClientProvider client={qc}>
|
|
<PageToolbarSlot.Provider value={slot}>
|
|
<Activity />
|
|
</PageToolbarSlot.Provider>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
// react-query hands its updates to React on a (real) timer tick, after the promise that caused them.
|
|
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 20)));
|
|
|
|
const lines = () =>
|
|
within(screen.getByTestId("admin-logs-lines"))
|
|
.queryAllByRole("button")
|
|
.filter((b) => b.hasAttribute("aria-expanded"));
|
|
|
|
beforeEach(() => {
|
|
window.history.replaceState(null, "", "/?page=activity&at=logs");
|
|
facetsMock.mockResolvedValue(FACETS);
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
cleanup();
|
|
logsMock.mockReset();
|
|
facetsMock.mockReset();
|
|
});
|
|
|
|
describe("AdminLogs", () => {
|
|
it("a request id narrows the list to that request, with every other filter off", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity&at=logs&lgrp=error&aq=boom");
|
|
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
|
|
renderConsole();
|
|
|
|
await waitFor(() => expect(lines()).toHaveLength(1));
|
|
fireEvent.click(lines()[0]!);
|
|
fireEvent.click(screen.getByRole("button", { name: "req-00000001" }));
|
|
|
|
await waitFor(() =>
|
|
expect(logsMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ request_id: "req-00000001", levels: undefined, q: undefined }),
|
|
null,
|
|
),
|
|
);
|
|
expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy();
|
|
const url = new URLSearchParams(window.location.search);
|
|
expect([url.get("lreq"), url.get("lgrp"), url.get("aq")]).toEqual(["req-00000001", null, null]);
|
|
});
|
|
|
|
it("Back from a request's lines returns to the view the pivot was made from", async () => {
|
|
window.history.replaceState(
|
|
{ sfPage: "activity" },
|
|
"",
|
|
"/?page=activity&at=logs&lgrp=error&aq=boom",
|
|
);
|
|
const depth = window.history.length;
|
|
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
|
|
renderConsole();
|
|
await waitFor(() => expect(lines()).toHaveLength(1));
|
|
fireEvent.click(lines()[0]!);
|
|
fireEvent.click(screen.getByRole("button", { name: "req-00000001" }));
|
|
await waitFor(() =>
|
|
expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy(),
|
|
);
|
|
expect(window.history.length).toBe(depth + 1);
|
|
|
|
await act(async () => {
|
|
const landed = new Promise((resolve) =>
|
|
window.addEventListener("popstate", resolve, { once: true }),
|
|
);
|
|
window.history.back();
|
|
await landed;
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(logsMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
request_id: undefined,
|
|
levels: ["ERROR", "CRITICAL"],
|
|
q: "boom",
|
|
}),
|
|
null,
|
|
),
|
|
);
|
|
expect(screen.queryByText("adminLogs.pivotRequest:req-00000001")).toBeNull();
|
|
const url = new URLSearchParams(window.location.search);
|
|
expect([url.get("lreq"), url.get("lgrp"), url.get("aq")]).toEqual([null, "error", "boom"]);
|
|
// Neither jump waited out the typing debounce: no read ever mixed one view's filters with the
|
|
// other's search text (the request with "boom", or the restored view without it).
|
|
const mixed = logsMock.mock.calls.filter(([q]) => (q.request_id != null) === (q.q === "boom"));
|
|
expect(mixed).toEqual([]);
|
|
});
|
|
|
|
it("a run id narrows the list to that run's lines", async () => {
|
|
logsMock.mockResolvedValue({
|
|
items: [row(1, { run_id: 77 })],
|
|
accounts: {},
|
|
next_cursor: null,
|
|
});
|
|
renderConsole();
|
|
await waitFor(() => expect(lines()).toHaveLength(1));
|
|
fireEvent.click(lines()[0]!);
|
|
fireEvent.click(screen.getByRole("button", { name: "77" }));
|
|
|
|
await waitFor(() =>
|
|
expect(logsMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ run_id: 77, request_id: undefined }),
|
|
null,
|
|
),
|
|
);
|
|
expect(screen.getByText("adminLogs.pivotRun:77")).toBeTruthy();
|
|
expect(new URLSearchParams(window.location.search).get("lrun")).toBe("77");
|
|
});
|
|
|
|
it("an old link to the server log page opens this tab with its filter", async () => {
|
|
window.history.replaceState(null, "", "/?page=logs&lreq=req-00000009");
|
|
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
|
|
renderConsole();
|
|
|
|
await waitFor(() =>
|
|
expect(logsMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ request_id: "req-00000009" }),
|
|
null,
|
|
),
|
|
);
|
|
expect(new URLSearchParams(window.location.search).get("page")).toBe("activity");
|
|
});
|
|
|
|
it("keeps the account in use offered when the account list does not have it", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity&at=logs&au=7");
|
|
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
|
|
renderConsole();
|
|
await waitFor(() =>
|
|
expect(logsMock).toHaveBeenCalledWith(expect.objectContaining({ user: "7" }), null),
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId("activity-user"));
|
|
|
|
expect(await screen.findByTestId("activity-user-7")).toBeTruthy();
|
|
expect(screen.getByTestId("activity-user-1")).toBeTruthy();
|
|
});
|
|
|
|
it("Live watches the newest page only", async () => {
|
|
logsMock.mockImplementation(async (_q, before) =>
|
|
before === null
|
|
? { items: [row(3), row(2)], accounts: {}, next_cursor: "c1" }
|
|
: { items: [row(1)], accounts: {}, next_cursor: null },
|
|
);
|
|
renderConsole();
|
|
await waitFor(() => expect(lines()).toHaveLength(2));
|
|
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
|
|
await waitFor(() => expect(lines()).toHaveLength(3));
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
|
|
|
|
await waitFor(() => expect(lines()).toHaveLength(2));
|
|
expect(
|
|
screen.getByRole("button", { name: "adminLogs.live" }).getAttribute("aria-pressed"),
|
|
).toBe("true");
|
|
});
|
|
|
|
it("a page that lands after Live went on is dropped at the next tick", async () => {
|
|
let landOlder!: (page: LogPage) => void;
|
|
logsMock.mockImplementation((_q, before) =>
|
|
before === null
|
|
? Promise.resolve({ items: [row(3), row(2)], accounts: {}, next_cursor: "c1" })
|
|
: new Promise<LogPage>((resolve) => (landOlder = resolve)),
|
|
);
|
|
renderConsole();
|
|
await waitFor(() => expect(lines()).toHaveLength(2));
|
|
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
|
|
await waitFor(() => expect(landOlder).toBeTypeOf("function"));
|
|
|
|
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
|
|
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
|
|
await act(async () => landOlder({ items: [row(1)], accounts: {}, next_cursor: null }));
|
|
await settle();
|
|
expect(lines()).toHaveLength(3);
|
|
const olderReads = logsMock.mock.calls.filter(([, before]) => before !== null).length;
|
|
|
|
await act(async () => vi.advanceTimersByTimeAsync(4000));
|
|
await settle();
|
|
|
|
expect(lines()).toHaveLength(2);
|
|
expect(logsMock.mock.calls.filter(([, before]) => before !== null)).toHaveLength(olderReads);
|
|
});
|
|
|
|
it("a failed live read stops the polling and says the list may be old", async () => {
|
|
logsMock.mockResolvedValueOnce({ items: [row(1)], accounts: {}, next_cursor: null });
|
|
renderConsole();
|
|
await waitFor(() => expect(lines()).toHaveLength(1));
|
|
|
|
logsMock.mockRejectedValue(new Error("500"));
|
|
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
|
|
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
|
|
await act(async () => vi.advanceTimersByTimeAsync(4000));
|
|
await settle();
|
|
|
|
expect(screen.getByRole("alert").textContent).toContain("adminLogs.stale");
|
|
expect(lines()).toHaveLength(1);
|
|
const reads = logsMock.mock.calls.length;
|
|
await act(async () => vi.advanceTimersByTimeAsync(12000));
|
|
expect(logsMock.mock.calls.length).toBe(reads);
|
|
// The button says polling stopped, and pressing it reads again.
|
|
const liveButton = screen.getByRole("button", { name: "adminLogs.live" });
|
|
expect(liveButton.getAttribute("aria-pressed")).toBe("false");
|
|
fireEvent.click(liveButton);
|
|
await settle();
|
|
expect(logsMock.mock.calls.length).toBe(reads + 1);
|
|
});
|
|
|
|
it("a failed older page says so, not that the list is out of date", async () => {
|
|
logsMock.mockImplementation(async (_q, before) => {
|
|
if (before !== null) throw new Error("500");
|
|
return { items: [row(2)], accounts: {}, next_cursor: "c1" };
|
|
});
|
|
renderConsole();
|
|
await waitFor(() => expect(lines()).toHaveLength(1));
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
|
|
|
|
expect((await screen.findByRole("alert")).textContent).toContain("adminLogs.olderFailed");
|
|
});
|
|
|
|
it("offers no older page while a filter change loads", async () => {
|
|
let landErrors!: (page: LogPage) => void;
|
|
logsMock.mockImplementation((q) =>
|
|
q.levels
|
|
? new Promise<LogPage>((resolve) => (landErrors = resolve))
|
|
: Promise.resolve({ items: [row(2)], accounts: {}, next_cursor: "c1" }),
|
|
);
|
|
renderConsole();
|
|
await waitFor(() => expect(lines()).toHaveLength(1));
|
|
expect(screen.queryByRole("button", { name: "adminLogs.older" })).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.error/ }));
|
|
await waitFor(() => expect(landErrors).toBeTypeOf("function"));
|
|
|
|
// The rows on screen are the previous filter's: their cursor is not this list's.
|
|
expect(screen.queryByRole("button", { name: "adminLogs.older" })).toBeNull();
|
|
await act(async () => landErrors({ items: [row(1)], accounts: {}, next_cursor: null }));
|
|
});
|
|
|
|
it("copies every loaded line, oldest first", async () => {
|
|
logsMock.mockImplementation(async (_q, before) =>
|
|
before === null
|
|
? { items: [row(3), row(2)], accounts: {}, next_cursor: "c1" }
|
|
: { items: [row(1)], accounts: {}, next_cursor: null },
|
|
);
|
|
renderConsole();
|
|
await waitFor(() => expect(lines()).toHaveLength(2));
|
|
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
|
|
await waitFor(() => expect(lines()).toHaveLength(3));
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole("button", { name: "adminLogs.copy" }));
|
|
});
|
|
|
|
const ids = copied[copied.length - 1]!.split("\n").map((l) => JSON.parse(l).id);
|
|
expect(ids).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("the keyboard can walk the lines and open one (NavGrid items)", async () => {
|
|
logsMock.mockResolvedValue({ items: [row(2), row(1)], accounts: {}, next_cursor: null });
|
|
renderConsole();
|
|
await waitFor(() => expect(lines()).toHaveLength(2));
|
|
|
|
const items = lines().map((b) => b.parentElement!);
|
|
expect(items.map((el) => el.getAttribute("data-nav-index"))).toEqual(["0", "1"]);
|
|
expect(lines().every((b) => b.hasAttribute("data-nav-open"))).toBe(true);
|
|
expect(screen.getByTestId("admin-logs-lines").querySelector("[data-nav-list]")).toBeTruthy();
|
|
});
|
|
|
|
it("a failed read is an error with a retry, not an empty log", async () => {
|
|
logsMock.mockRejectedValue(new Error("502"));
|
|
renderConsole();
|
|
|
|
expect(await screen.findByRole("alert")).toBeTruthy();
|
|
expect(screen.queryByText("adminLogs.empty")).toBeNull();
|
|
});
|
|
});
|