feat(activity): one Activity page — runs, admin actions, server log and quota under one filter bar

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.
This commit is contained in:
2026-10-18 03:45:18 +02:00
parent d13483a593
commit 24448e7c6f
66 changed files with 2723 additions and 1119 deletions
+68 -17
View File
@@ -1,9 +1,11 @@
// @vitest-environment jsdom
//
// The admin log console. A request id narrows the list to that request (and says so, in the
// filter row and the URL); a filter value the facets no longer count 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.
// 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";
@@ -32,6 +34,7 @@ vi.mock("../lib/api", async (importOriginal) => {
...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" }]),
},
};
});
@@ -44,7 +47,11 @@ vi.mock("../lib/clipboard", () => ({
},
}));
import AdminLogs from "./AdminLogs";
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 {
@@ -60,6 +67,7 @@ function row(id: number, extra: Partial<ServerLogRow> = {}): ServerLogRow {
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,
@@ -79,7 +87,9 @@ function renderConsole() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<AdminLogs />
<PageToolbarSlot.Provider value={slot}>
<Activity />
</PageToolbarSlot.Provider>
</QueryClientProvider>,
);
}
@@ -93,7 +103,7 @@ const lines = () =>
.filter((b) => b.hasAttribute("aria-expanded"));
beforeEach(() => {
window.history.replaceState(null, "", "/?page=logs");
window.history.replaceState(null, "", "/?page=activity&at=logs");
facetsMock.mockResolvedValue(FACETS);
});
afterEach(() => {
@@ -105,7 +115,7 @@ afterEach(() => {
describe("AdminLogs", () => {
it("a request id narrows the list to that request, with every other filter off", async () => {
window.history.replaceState(null, "", "/?page=logs&lgrp=error&lq=boom");
window.history.replaceState(null, "", "/?page=activity&at=logs&lgrp=error&aq=boom");
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
renderConsole();
@@ -121,11 +131,15 @@ describe("AdminLogs", () => {
);
expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy();
const url = new URLSearchParams(window.location.search);
expect([url.get("lreq"), url.get("lgrp"), url.get("lq")]).toEqual(["req-00000001", null, null]);
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: "logs" }, "", "/?page=logs&lgrp=error&lq=boom");
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();
@@ -157,23 +171,60 @@ describe("AdminLogs", () => {
);
expect(screen.queryByText("adminLogs.pivotRequest:req-00000001")).toBeNull();
const url = new URLSearchParams(window.location.search);
expect([url.get("lreq"), url.get("lgrp"), url.get("lq")]).toEqual([null, "error", "boom"]);
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("keeps the account in use offered when the facets no longer count it", async () => {
window.history.replaceState(null, "", "/?page=logs&lu=7");
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(facetsMock).toHaveBeenCalled());
fireEvent.click(screen.getByTestId("logs-user"));
await waitFor(() =>
expect(logsMock).toHaveBeenCalledWith(
expect.objectContaining({ request_id: "req-00000009" }),
null,
),
);
expect(new URLSearchParams(window.location.search).get("page")).toBe("activity");
});
expect(await screen.findByTestId("logs-user-7")).toBeTruthy();
expect(screen.getByTestId("logs-user-1")).toBeTruthy();
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 () => {