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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user