Backend - A run's outcome write sits outside the job's own try: a database blip after a run that succeeded no longer records it as failed (the row waits for the next start's sweep). - An interrupted run keeps what it spent (the sweep sums its quota rows, as finish does). - The Scheduler's ad-hoc row says running while any backfill runs, not only the newest. - recent_runs reads each job's last runs off its index instead of ranking the whole table. - An interval change is stored with its audit row in one transaction, then rescheduled. - One API instance per database is the stated contract (.env.example, jobruns). - Turning full history on for a channel already complete records no run. - Migration 0109 round-trips interrupted and ad-hoc runs; the run words' DB CHECKs are test-held to the enums; the account filter is one helper for the four activity reads. - An audit row says how many of its request's log lines are still kept. Frontend - An "All" period reaches every row still kept (runs and admin actions outlive 90 days). - Back/Forward is followed in place by the server log: a closing dialog no longer turns Live off; a pivot still opens a fresh log. - Runs moved over from the audit log read their one-liner in the reader's language. - Filter choices or accounts that failed to load say so beside the filters, with a retry. - An old server-log link keeps its account, text and range; a remembered retired page opens its tab. - A request whose lines are gone says so instead of linking to an empty list. - The log lines and the history rows share one row frame, and the server log one list frame with the other tabs; an empty list is announced; each day's quota is readable.
402 lines
17 KiB
TypeScript
402 lines
17 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The activity page around its run and audit lists (the server-log tab has its own file): the view
|
|
// is the URL — a link narrows the runs to one job, a status chip narrows them further; a run opens
|
|
// to its log lines and an admin action to its request's, each a new history entry that Back undoes;
|
|
// the account filter reaches the quota tab; a
|
|
// failed read is an error with a retry, never an empty list.
|
|
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 { AuditQuery, AuditRow, HistoryPage, LogQuery, RunQuery, RunRow } from "../lib/api";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
|
};
|
|
});
|
|
|
|
const runsMock = vi.fn<(q: RunQuery, before: string | null) => Promise<HistoryPage<RunRow>>>();
|
|
const auditMock = vi.fn<(q: AuditQuery, before: string | null) => Promise<HistoryPage<AuditRow>>>();
|
|
const logsMock = vi.fn<(q: LogQuery, before: string | null) => Promise<unknown>>();
|
|
const quotaMock = vi.fn<(since: string, user: string | null) => Promise<unknown>>();
|
|
const runFacetsMock = vi.fn<() => Promise<unknown>>();
|
|
const usersMock = vi.fn<() => Promise<unknown>>();
|
|
vi.mock("../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../lib/api")>();
|
|
return {
|
|
...actual,
|
|
api: {
|
|
...actual.api,
|
|
adminRuns: (q: RunQuery, before: string | null) => runsMock(q, before),
|
|
adminRunFacets: () => runFacetsMock(),
|
|
adminAudit: (q: AuditQuery, before: string | null) => auditMock(q, before),
|
|
adminAuditFacets: () => Promise.resolve({ actions: [] }),
|
|
adminLogs: (q: LogQuery, before: string | null) => logsMock(q, before),
|
|
adminLogFacets: () =>
|
|
Promise.resolve({ levels: [], sources: [], events: [], users: [], accounts: {}, total: 0 }),
|
|
adminUsers: () => usersMock(),
|
|
adminQuota: (since: string, user: string | null) => quotaMock(since, user),
|
|
clearAudit: () => Promise.resolve({ ok: true, cleared: 1 }),
|
|
},
|
|
};
|
|
});
|
|
|
|
import Activity from "./Activity";
|
|
import { ChoiceContext } from "./confirmContext";
|
|
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"));
|
|
|
|
const RUN: RunRow = {
|
|
id: 41,
|
|
job: "backfill",
|
|
trigger: "manual",
|
|
actor_id: 1,
|
|
target: null,
|
|
target_title: null,
|
|
status: "error",
|
|
started_at: "2026-10-18T02:31:04Z",
|
|
finished_at: "2026-10-18T02:31:45Z",
|
|
summary: null,
|
|
result: null,
|
|
error: "quotaExceeded",
|
|
failure: null,
|
|
quota_units: 212,
|
|
log_lines: 9,
|
|
};
|
|
|
|
const ACTION: AuditRow = {
|
|
id: 5,
|
|
created_at: "2026-10-18T02:27:00Z",
|
|
actor_id: 1,
|
|
action: "scheduler_interval",
|
|
target_type: "job",
|
|
target_id: "rss_poll",
|
|
summary: "rss_poll → 25 min/run",
|
|
before: null,
|
|
after: { interval_minutes: 25 },
|
|
request_id: "0efdd960c9ad1977",
|
|
log_lines: 4,
|
|
};
|
|
|
|
const page = <T,>(items: T[]): HistoryPage<T> => ({
|
|
items,
|
|
accounts: { "1": "admin@example.com" },
|
|
next_cursor: null,
|
|
});
|
|
|
|
function renderPage() {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
return render(
|
|
<QueryClientProvider client={qc}>
|
|
{/* Every confirmation is answered yes. */}
|
|
<ChoiceContext.Provider value={() => Promise.resolve("confirm")}>
|
|
<PageToolbarSlot.Provider value={slot}>
|
|
<Activity />
|
|
</PageToolbarSlot.Provider>
|
|
</ChoiceContext.Provider>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
const url = () => new URLSearchParams(window.location.search);
|
|
const rowButton = (text: RegExp) =>
|
|
screen
|
|
.getAllByRole("button")
|
|
.find((b) => b.hasAttribute("aria-expanded") && text.test(b.textContent ?? ""))!;
|
|
|
|
beforeEach(() => {
|
|
runsMock.mockResolvedValue(page([RUN]));
|
|
auditMock.mockResolvedValue(page([ACTION]));
|
|
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
|
|
quotaMock.mockResolvedValue({ rows: [], daily: [] });
|
|
runFacetsMock.mockResolvedValue({ jobs: [], statuses: [], total: 0 });
|
|
usersMock.mockResolvedValue([{ id: 1, email: "admin@example.com" }]);
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("Activity", () => {
|
|
it("a link narrows the runs to one job, and a status chip narrows them further", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity&rjob=backfill");
|
|
renderPage();
|
|
await waitFor(() =>
|
|
expect(runsMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ job: "backfill", status: undefined, since: expect.any(String) }),
|
|
null,
|
|
),
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /activity\.runs\.status\.error/ }));
|
|
|
|
await waitFor(() =>
|
|
expect(runsMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ job: "backfill", status: "error" }),
|
|
null,
|
|
),
|
|
);
|
|
expect([url().get("rjob"), url().get("rst")]).toEqual(["backfill", "error"]);
|
|
});
|
|
|
|
it("Older reads the next page from its cursor, and offers none past the last", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity");
|
|
runsMock.mockImplementation((_q, before) =>
|
|
Promise.resolve(
|
|
before === null
|
|
? { ...page([RUN]), next_cursor: "c1" }
|
|
: page([{ ...RUN, id: 40, error: "the older one" }]),
|
|
),
|
|
);
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole("button", { name: "activity.older" }));
|
|
await waitFor(() => expect(screen.getByText("the older one")).toBeTruthy());
|
|
expect(runsMock).toHaveBeenLastCalledWith(expect.anything(), "c1");
|
|
expect(screen.getByText("quotaExceeded")).toBeTruthy();
|
|
expect(screen.queryByRole("button", { name: "activity.older" })).toBeNull();
|
|
});
|
|
|
|
it("an older page that fails says so and keeps what was loaded", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity");
|
|
runsMock.mockImplementation((_q, before) =>
|
|
before === null
|
|
? Promise.resolve({ ...page([RUN]), next_cursor: "c1" })
|
|
: Promise.reject(new Error("500")),
|
|
);
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole("button", { name: "activity.older" }));
|
|
const alert = await screen.findByRole("alert");
|
|
expect(alert.textContent).toContain("activity.olderFailed");
|
|
expect(screen.getByText("quotaExceeded")).toBeTruthy();
|
|
});
|
|
|
|
it("offers no older page while a filter change is still loading", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity");
|
|
// The cursor on screen is the previous filter's: it would continue the wrong list.
|
|
runsMock.mockImplementation((q) =>
|
|
q.status ? new Promise(() => {}) : Promise.resolve({ ...page([RUN]), next_cursor: "c1" }),
|
|
);
|
|
renderPage();
|
|
await screen.findByRole("button", { name: "activity.older" });
|
|
fireEvent.click(screen.getByRole("button", { name: /activity\.runs\.status\.error/ }));
|
|
await waitFor(() =>
|
|
expect(screen.queryByRole("button", { name: "activity.older" })).toBeNull(),
|
|
);
|
|
expect(screen.getByText("quotaExceeded")).toBeTruthy(); // the old rows stay, dimmed
|
|
});
|
|
|
|
it("a run opens to its log lines, and Back returns to the runs", async () => {
|
|
window.history.replaceState({ sfPage: "activity" }, "", "/?page=activity&aw=24h&aq=quota");
|
|
renderPage();
|
|
await waitFor(() => expect(screen.getByText("quotaExceeded")).toBeTruthy());
|
|
fireEvent.click(rowButton(/quotaExceeded/));
|
|
fireEvent.click(screen.getByRole("button", { name: "activity.runs.logLines" }));
|
|
|
|
await waitFor(() =>
|
|
expect(logsMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ run_id: 41, q: undefined }),
|
|
null,
|
|
),
|
|
);
|
|
// Every other filter is off, and the range is the log's whole retention.
|
|
expect([url().get("at"), url().get("lrun"), url().get("aw"), url().get("aq")]).toEqual([
|
|
"logs",
|
|
"41",
|
|
"90d",
|
|
null,
|
|
]);
|
|
|
|
await act(async () => {
|
|
const landed = new Promise((resolve) =>
|
|
window.addEventListener("popstate", resolve, { once: true }),
|
|
);
|
|
window.history.back();
|
|
await landed;
|
|
});
|
|
await waitFor(() => expect(url().get("at")).toBeNull());
|
|
expect([url().get("aw"), url().get("aq"), url().get("lrun")]).toEqual(["24h", "quota", null]);
|
|
expect(screen.getByText("quotaExceeded")).toBeTruthy();
|
|
});
|
|
|
|
it("a run's outcome is its result in the reader's words, its failure in the danger colour", async () => {
|
|
runsMock.mockResolvedValue(
|
|
page<RunRow>([
|
|
{ ...RUN, id: 1, status: "ok", error: null, result: { new: 3 }, summary: "new=3" },
|
|
// Moved over from the audit log before the result rode along as data: the one-liner only.
|
|
{ ...RUN, id: 2, status: "ok", error: null, result: null, summary: "failed=2" },
|
|
{ ...RUN, id: 3, status: "interrupted", error: null },
|
|
{ ...RUN, id: 4 },
|
|
]),
|
|
);
|
|
renderPage();
|
|
await waitFor(() => expect(screen.getByText("scheduler.result.new: 3")).toBeTruthy());
|
|
expect(screen.queryByText("new=3")).toBeNull();
|
|
expect(screen.getByText("scheduler.result.failed: 2")).toBeTruthy();
|
|
expect(screen.getByText("scheduler.interrupted").className).toContain("text-warn");
|
|
expect(screen.getByText("quotaExceeded").className).toContain("text-danger");
|
|
});
|
|
|
|
it("filter choices that failed to load say so beside the filters, with a retry", async () => {
|
|
runFacetsMock.mockRejectedValueOnce(new Error("500"));
|
|
usersMock.mockRejectedValueOnce(new Error("500"));
|
|
renderPage();
|
|
await waitFor(() =>
|
|
expect(screen.getAllByRole("alert").map((a) => a.textContent)).toEqual([
|
|
expect.stringContaining("activity.accountsFailed"),
|
|
expect.stringContaining("activity.filtersFailed"),
|
|
]),
|
|
);
|
|
for (const retry of screen.getAllByRole("button", { name: "common.retry" }))
|
|
fireEvent.click(retry);
|
|
await waitFor(() => expect(screen.queryAllByRole("alert")).toEqual([]));
|
|
expect(runFacetsMock).toHaveBeenCalledTimes(2);
|
|
expect(usersMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("clearing the admin actions reads the list again", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity&at=admin");
|
|
renderPage();
|
|
await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy());
|
|
auditMock.mockResolvedValue(page<AuditRow>([]));
|
|
fireEvent.click(screen.getByRole("button", { name: "audit.clear" }));
|
|
await waitFor(() => expect(screen.getByText("audit.empty")).toBeTruthy());
|
|
// An emptied list is announced, as the error and loading states are.
|
|
expect(screen.getByText("audit.empty").closest('[role="status"]')).toBeTruthy();
|
|
});
|
|
|
|
it("closing a dialog over the server log leaves Live on", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity&at=logs");
|
|
renderPage();
|
|
const live = await screen.findByRole("button", { name: "adminLogs.live" });
|
|
fireEvent.click(live);
|
|
expect(live.getAttribute("aria-pressed")).toBe("true");
|
|
// A closing dialog steps back over its own history entry: a popstate onto the same address.
|
|
act(() => {
|
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
});
|
|
const after = screen.getByRole("button", { name: "adminLogs.live" });
|
|
expect(after.getAttribute("aria-pressed")).toBe("true");
|
|
expect(after).toBe(live); // the same log, not a fresh one
|
|
});
|
|
|
|
it("an action whose request's lines are gone says so instead of linking to an empty list", async () => {
|
|
auditMock.mockResolvedValue(page([{ ...ACTION, log_lines: 0 }]));
|
|
window.history.replaceState(null, "", "/?page=activity&at=admin");
|
|
renderPage();
|
|
await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy());
|
|
fireEvent.click(rowButton(/scheduler_interval/));
|
|
expect(screen.getByText("activity.audit.noRequestLines")).toBeTruthy();
|
|
expect(screen.queryByRole("button", { name: "activity.audit.requestLines" })).toBeNull();
|
|
});
|
|
|
|
it("the admin tab lists the actions, and an action opens its request's lines", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity&at=admin");
|
|
renderPage();
|
|
await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy());
|
|
expect(runsMock).not.toHaveBeenCalled();
|
|
|
|
fireEvent.click(rowButton(/auditActions\.scheduler_interval/));
|
|
fireEvent.click(screen.getByRole("button", { name: "activity.audit.requestLines" }));
|
|
|
|
await waitFor(() =>
|
|
expect(logsMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ request_id: "0efdd960c9ad1977" }),
|
|
null,
|
|
),
|
|
);
|
|
expect(url().get("lreq")).toBe("0efdd960c9ad1977");
|
|
});
|
|
|
|
it("the account filter reaches the quota tab, over the page's range", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity&at=quota&au=none&aw=30d");
|
|
renderPage();
|
|
await waitFor(() => expect(quotaMock).toHaveBeenCalled());
|
|
const [since, user] = quotaMock.mock.calls[0]!;
|
|
expect(user).toBe("none");
|
|
const days = (Date.now() - new Date(since).getTime()) / 86_400_000;
|
|
expect(Math.round(days)).toBe(30);
|
|
});
|
|
|
|
it("each day's spend is readable without a pointer", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity&at=quota");
|
|
quotaMock.mockResolvedValue({
|
|
rows: [{ user_id: null, email: "System", total: 9, by_action: {} }],
|
|
daily: [
|
|
{ day: "2026-10-16", total: 4 },
|
|
{ day: "2026-10-17", total: 5 },
|
|
],
|
|
});
|
|
renderPage();
|
|
const chart = await screen.findByRole("list", { name: "activity.quota.daily" });
|
|
expect(
|
|
within(chart)
|
|
.getAllByRole("listitem")
|
|
.map((d) => d.textContent),
|
|
).toEqual(["stats.dailyTooltip", "stats.dailyTooltip"]);
|
|
});
|
|
|
|
// The runs moved over from the audit log kept one instant for start and end: their length is
|
|
// unknown, and a "1 sec" would be invented.
|
|
it("a run whose start and end are one instant has no length", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity");
|
|
runsMock.mockResolvedValue(
|
|
page([
|
|
{ ...RUN, id: 1, status: "ok", error: null, finished_at: RUN.started_at, summary: "moved" },
|
|
{ ...RUN, id: 2, status: "ok", error: null, summary: "timed" },
|
|
]),
|
|
);
|
|
renderPage();
|
|
await waitFor(() => expect(screen.getByText("moved")).toBeTruthy());
|
|
const cells = (text: string) =>
|
|
[...rowButton(new RegExp(text)).children].map((c) => c.textContent);
|
|
expect(cells("moved")).toContain("—");
|
|
expect(cells("timed")).not.toContain("—");
|
|
});
|
|
|
|
// A bare "212" in a row reads as nothing in particular: the spend carries its unit, and a run that
|
|
// spent none says nothing rather than "0".
|
|
it("a run's quota spend reads with its unit, and none spent leaves the cell blank", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity");
|
|
runsMock.mockResolvedValue(
|
|
page([
|
|
{ ...RUN, id: 1, status: "ok", error: null, summary: "spent" },
|
|
{ ...RUN, id: 2, status: "ok", error: null, summary: "free", quota_units: 0 },
|
|
]),
|
|
);
|
|
renderPage();
|
|
await waitFor(() => expect(screen.getByText("spent")).toBeTruthy());
|
|
const cells = (text: string) =>
|
|
[...rowButton(new RegExp(text)).children].map((c) => c.textContent);
|
|
expect(cells("spent")).toContain("activity.runs.units");
|
|
expect(cells("spent")).not.toContain("212");
|
|
expect(cells("free")).not.toContain("0");
|
|
expect(cells("free")).not.toContain("activity.runs.units");
|
|
});
|
|
|
|
// Most runs store no line at all: a link there would only lead to an empty list.
|
|
it("a run that logged nothing says so instead of linking to an empty list", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity");
|
|
runsMock.mockResolvedValue(page([{ ...RUN, log_lines: 0 }]));
|
|
renderPage();
|
|
await waitFor(() => expect(screen.getByText("quotaExceeded")).toBeTruthy());
|
|
fireEvent.click(rowButton(/quotaExceeded/));
|
|
expect(screen.getByText("activity.runs.noLogLines")).toBeTruthy();
|
|
expect(screen.queryByRole("button", { name: "activity.runs.logLines" })).toBeNull();
|
|
});
|
|
|
|
it("a failed read is an error with a retry, not an empty list", async () => {
|
|
window.history.replaceState(null, "", "/?page=activity");
|
|
runsMock.mockRejectedValue(new Error("502"));
|
|
renderPage();
|
|
|
|
expect(await screen.findByRole("alert")).toBeTruthy();
|
|
expect(screen.queryByText("activity.runs.empty")).toBeNull();
|
|
});
|
|
});
|