fix(activity): runs keep their true outcome and spend, the page reaches every kept row, a closing dialog leaves the server log alone

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.
This commit is contained in:
2026-10-18 15:38:32 +02:00
parent 3021be5166
commit fad656988a
44 changed files with 1101 additions and 426 deletions
+151 -6
View File
@@ -6,7 +6,7 @@
// 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 } from "@testing-library/react";
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";
@@ -23,6 +23,8 @@ const runsMock = vi.fn<(q: RunQuery, before: string | null) => Promise<HistoryPa
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 {
@@ -30,19 +32,21 @@ vi.mock("../lib/api", async (importOriginal) => {
api: {
...actual.api,
adminRuns: (q: RunQuery, before: string | null) => runsMock(q, before),
adminRunFacets: () => Promise.resolve({ jobs: [], statuses: [], total: 0 }),
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: () => Promise.resolve([{ id: 1, email: "admin@example.com" }]),
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.
@@ -77,6 +81,7 @@ const ACTION: AuditRow = {
before: null,
after: { interval_minutes: 25 },
request_id: "0efdd960c9ad1977",
log_lines: 4,
};
const page = <T,>(items: T[]): HistoryPage<T> => ({
@@ -89,9 +94,12 @@ function renderPage() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<PageToolbarSlot.Provider value={slot}>
<Activity />
</PageToolbarSlot.Provider>
{/* Every confirmation is answered yes. */}
<ChoiceContext.Provider value={() => Promise.resolve("confirm")}>
<PageToolbarSlot.Provider value={slot}>
<Activity />
</PageToolbarSlot.Provider>
</ChoiceContext.Provider>
</QueryClientProvider>,
);
}
@@ -107,6 +115,8 @@ beforeEach(() => {
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();
@@ -135,6 +145,52 @@ describe("Activity", () => {
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();
@@ -168,6 +224,77 @@ describe("Activity", () => {
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();
@@ -196,6 +323,24 @@ describe("Activity", () => {
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 () => {