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 () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import FilterSelect from "./ui/FilterSelect";
|
||||
import { InnerInput } from "./ui/Input";
|
||||
import { fieldClass } from "./ui/fieldStyles";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { StateMessage } from "./ui/QueryState";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { useTabPanel } from "./ui/tabPanel";
|
||||
|
||||
@@ -51,9 +52,12 @@ export default function Activity() {
|
||||
activityParamsToState(new URLSearchParams(window.location.search)),
|
||||
);
|
||||
// Bumped when the view changes by a jump (a pivot, Back/Forward) rather than by typing: the search
|
||||
// text then applies at once instead of after the typing debounce, and the server log re-reads its
|
||||
// own filters from the address bar.
|
||||
// text then applies at once instead of after the typing debounce.
|
||||
const [jumps, setJumps] = useState(0);
|
||||
// Bumped by a pivot only: the server log, already open, takes the pivot's filters from the address
|
||||
// bar when it mounts again. Back/Forward it follows in place (AdminLogs) — a popstate is also a
|
||||
// dialog closing, onto the same address, and a fresh log would drop its Live and its focus.
|
||||
const [pivots, setPivots] = useState(0);
|
||||
const q = useDebounced(state.q, 300, jumps);
|
||||
const panel = useTabPanel();
|
||||
// `page` too: a link to a page this one replaced (audit, logs) is rewritten to this page's id.
|
||||
@@ -81,10 +85,13 @@ export default function Activity() {
|
||||
);
|
||||
notifyNav();
|
||||
setJumps((n) => n + 1);
|
||||
setPivots((n) => n + 1);
|
||||
setState(next);
|
||||
};
|
||||
|
||||
const shared = { window: state.window, user: state.user, q };
|
||||
// The account menu's list (SharedBar reads the same query): without it the menu offers only "Any".
|
||||
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
|
||||
return (
|
||||
<div {...panel.panelProps(state.tab)} className="px-4 pb-4 pt-3 max-w-6xl w-full mx-auto">
|
||||
<PageToolbar>
|
||||
@@ -99,6 +106,11 @@ export default function Activity() {
|
||||
<SharedBar state={state} onChange={update} />
|
||||
</div>
|
||||
</PageToolbar>
|
||||
{users.isError && (
|
||||
<StateMessage tone="error" compact onRetry={() => void users.refetch()}>
|
||||
{t("activity.accountsFailed")}
|
||||
</StateMessage>
|
||||
)}
|
||||
{/* The quota tab says what its numbers are itself, beside them. */}
|
||||
{state.tab !== "quota" && (
|
||||
<p className="mb-3 text-xs leading-relaxed text-muted">
|
||||
@@ -121,9 +133,9 @@ export default function Activity() {
|
||||
onRequest={(requestId) => pivot({ requestId, jobId: null, runId: null })}
|
||||
/>
|
||||
) : state.tab === "logs" ? (
|
||||
// Keyed by the jumps: a pivot or Back/Forward hands the log its filters through the address
|
||||
// bar, which it reads when it mounts.
|
||||
<AdminLogs key={jumps} shared={shared} onPivot={pivot} />
|
||||
// Keyed by the pivots: a pivot hands the log its filters through the address bar, which it
|
||||
// reads when it mounts.
|
||||
<AdminLogs key={pivots} shared={shared} onPivot={pivot} />
|
||||
) : (
|
||||
<ActivityQuota shared={shared} />
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { useHistoryList } from "../lib/useHistoryList";
|
||||
import type { Shared } from "./ActivityRuns";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { StateMessage } from "./ui/QueryState";
|
||||
import HistoryList from "./HistoryList";
|
||||
import HistoryRow from "./HistoryRow";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
@@ -67,8 +68,8 @@ export default function ActivityAudit({
|
||||
const clear = useMutation({
|
||||
mutationFn: () => api.clearAudit(),
|
||||
onSuccess: (r) => {
|
||||
void qc.invalidateQueries({ queryKey: ["admin-audit"] });
|
||||
void qc.invalidateQueries({ queryKey: ["admin-audit-facets"] });
|
||||
void qc.invalidateQueries({ queryKey: qk.adminAudit() });
|
||||
void qc.invalidateQueries({ queryKey: qk.adminAuditFacets() });
|
||||
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
||||
},
|
||||
onError: (e) => notifyActionError(e, t("audit.clearFailed")),
|
||||
@@ -115,6 +116,12 @@ export default function ActivityAudit({
|
||||
{t("audit.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
{/* The action menu above is built from these counts: without them it offers only "Any". */}
|
||||
{facets.isError && (
|
||||
<StateMessage tone="error" compact onRetry={() => void facets.refetch()}>
|
||||
{t("activity.filtersFailed")}
|
||||
</StateMessage>
|
||||
)}
|
||||
<HistoryList
|
||||
state={state}
|
||||
empty={t("audit.empty")}
|
||||
@@ -161,7 +168,6 @@ export default function ActivityAudit({
|
||||
);
|
||||
})}
|
||||
</HistoryList>
|
||||
{facets.isError && <p className="text-2xs text-danger">{t("activity.countsFailed")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -203,17 +209,21 @@ function AuditDetail({
|
||||
{JSON.stringify({ before: row.before, after: row.after }, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{row.request_id && (
|
||||
<div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="underline decoration-dotted underline-offset-2 hover:decoration-solid"
|
||||
onClick={() => onRequest(row.request_id!)}
|
||||
>
|
||||
{t("activity.audit.requestLines")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* A request's lines are kept days, its row months: a link to none would open an empty list. */}
|
||||
{row.request_id &&
|
||||
(row.log_lines > 0 ? (
|
||||
<div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="underline decoration-dotted underline-offset-2 hover:decoration-solid"
|
||||
onClick={() => onRequest(row.request_id!)}
|
||||
>
|
||||
{t("activity.audit.requestLines")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p>{t("activity.audit.noRequestLines")}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ export default function ActivityQuota({ shared }: { shared: Shared }) {
|
||||
)}
|
||||
<div className={`${SURFACE.card} p-3 mb-4`}>
|
||||
<div className="text-xs text-muted mb-2">
|
||||
{t("activity.quota.total", {
|
||||
{t(shared.window === "all" ? "activity.quota.totalAll" : "activity.quota.total", {
|
||||
count: grandTotal,
|
||||
window: t(`activity.windows.${shared.window}`),
|
||||
units: formatNumber(grandTotal, lang),
|
||||
})}
|
||||
@@ -57,18 +58,33 @@ export default function ActivityQuota({ shared }: { shared: Shared }) {
|
||||
{data.daily.length === 0 ? (
|
||||
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
|
||||
) : (
|
||||
<div className="flex items-end gap-0.5 h-24">
|
||||
// Each day's figure is in its bar's text too (read out, not shown): the tooltip only
|
||||
// reaches a pointer.
|
||||
<div
|
||||
className="flex items-end gap-0.5 h-24"
|
||||
role="list"
|
||||
aria-label={t("activity.quota.daily")}
|
||||
>
|
||||
{data.daily.map((d) => (
|
||||
<div
|
||||
key={d.day}
|
||||
role="listitem"
|
||||
// The day under the pointer is lit by the B3 layer over its whole column, not by
|
||||
// dimming the bar: an opacity dim reads as "disabled".
|
||||
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer"
|
||||
title={t("stats.dailyTooltip", {
|
||||
count: d.total,
|
||||
day: d.day,
|
||||
units: formatNumber(d.total, lang),
|
||||
})}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("stats.dailyTooltip", {
|
||||
count: d.total,
|
||||
day: d.day,
|
||||
units: formatNumber(d.total, lang),
|
||||
})}
|
||||
</span>
|
||||
<div
|
||||
className="w-full bg-accent rounded-t"
|
||||
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}
|
||||
|
||||
@@ -10,11 +10,12 @@ import {
|
||||
type RunStatusFilter,
|
||||
} from "../lib/activityUrlState";
|
||||
import { formatDateTimeSec, formatNumber, formatRuntime } from "../lib/format";
|
||||
import { jobFailureText, jobResultText } from "../lib/jobResult";
|
||||
import { jobFailureText, jobResultText, parseSummary } from "../lib/jobResult";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useHistoryList } from "../lib/useHistoryList";
|
||||
import HistoryList from "./HistoryList";
|
||||
import HistoryRow from "./HistoryRow";
|
||||
import { StateMessage } from "./ui/QueryState";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
import { StatusDot } from "./RunStatusDot";
|
||||
import { Button } from "./ui/Button";
|
||||
@@ -106,6 +107,12 @@ export default function ActivityRuns({
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{/* The menus above are built from these counts: without them they offer only "Any". */}
|
||||
{facets.isError && (
|
||||
<StateMessage tone="error" compact onRetry={() => void facets.refetch()}>
|
||||
{t("activity.filtersFailed")}
|
||||
</StateMessage>
|
||||
)}
|
||||
<HistoryList
|
||||
state={state}
|
||||
empty={t("activity.runs.empty")}
|
||||
@@ -136,7 +143,6 @@ export default function ActivityRuns({
|
||||
</HistoryRow>
|
||||
))}
|
||||
</HistoryList>
|
||||
{facets.isError && <p className="text-2xs text-danger">{t("activity.countsFailed")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -160,7 +166,13 @@ function useOutcome(run: RunRow): { text: string; tone: string } {
|
||||
};
|
||||
if (run.status === "interrupted") return { text: t("scheduler.interrupted"), tone: "text-warn" };
|
||||
if (run.status === "running") return { text: t("scheduler.runningNow"), tone: "text-accent" };
|
||||
if (run.result) return { text: jobResultText(t, run.result, lang), tone: "text-muted" };
|
||||
// A run moved over from the audit log before its result rode along as data holds only the
|
||||
// server's English one-liner: read back into a result, as the old audit rows were.
|
||||
const result =
|
||||
run.result ??
|
||||
parseSummary(run.summary) ??
|
||||
(run.status === "skipped" && run.summary ? { skipped: run.summary } : null);
|
||||
if (result) return { text: jobResultText(t, result, lang), tone: "text-muted" };
|
||||
return { text: run.summary ?? "", tone: "text-muted" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
keepPreviousData,
|
||||
@@ -18,14 +18,13 @@ import { logsParamsToState, logsParamUpdates, type LogsUrlState } from "../lib/l
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { HistoryFrame } from "./HistoryList";
|
||||
import LogLine from "./LogLine";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { Button } from "./ui/Button";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import { FilterFoldButton } from "./ui/FilterFold";
|
||||
import { useFilterFold } from "../lib/useFilterFold";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { DismissibleChip } from "./ui/Chip";
|
||||
import type { Shared } from "./ActivityRuns";
|
||||
|
||||
@@ -84,6 +83,19 @@ export default function AdminLogs({
|
||||
};
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
useUrlMirror(logsParamUpdates(state));
|
||||
// Back/Forward lands on another entry's filters; a dialog closing lands on these same ones, and
|
||||
// changes nothing (Live and the open line stay).
|
||||
const onLanded = useEffectEvent(() => {
|
||||
const landed = logsParamsToState(new URLSearchParams(window.location.search));
|
||||
if (JSON.stringify(landed) === JSON.stringify(state)) return;
|
||||
setOpen(null);
|
||||
setState(landed);
|
||||
});
|
||||
useEffect(() => {
|
||||
const onPop = () => onLanded();
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
const update = (patch: Partial<LogsUrlState>) => {
|
||||
setOpen(null);
|
||||
setState((s) => ({ ...s, ...patch }));
|
||||
@@ -212,156 +224,130 @@ export default function AdminLogs({
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="admin-logs" className="overflow-clip rounded-xl border border-border bg-bg">
|
||||
{/* The page's own band carries the shared filters and stays in reach; this one scrolls. */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("adminLogs.levelLabel")}
|
||||
value={state.group}
|
||||
onChange={(group) => update({ group })}
|
||||
options={GROUPS.map((g) => ({
|
||||
value: g,
|
||||
label: t(`settings.myLog.filters.${g}`),
|
||||
count: groupCount(g),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={facetFilters} />
|
||||
{!fold.folded && (
|
||||
<>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.source")}
|
||||
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
|
||||
selected={[state.sources[0] ?? ""]}
|
||||
onChange={([source]) => update({ sources: source ? [source] : [] })}
|
||||
testId="logs-source"
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.event")}
|
||||
options={facetOptions(facets.data?.events, state.event, (v) =>
|
||||
v === "none" ? t("adminLogs.noEvent") : v,
|
||||
)}
|
||||
selected={[state.event ?? ""]}
|
||||
onChange={([event]) => update({ event: event || null })}
|
||||
testId="logs-event"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto flex gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon={<Radio className="h-3.5 w-3.5" />}
|
||||
pressed={polling}
|
||||
onClick={toggleLive}
|
||||
>
|
||||
{t("adminLogs.live")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
// While a filter change loads, the rows on screen are the previous filter's.
|
||||
disabled={rows.length === 0 || logs.isPlaceholderData}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
{t("adminLogs.copy")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(state.requestId || state.jobId !== null || state.runId !== null) && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
|
||||
{state.requestId && (
|
||||
<PivotChip
|
||||
label={t("adminLogs.pivotRequest", { id: state.requestId })}
|
||||
clearLabel={t("adminLogs.clearPivot")}
|
||||
onClear={() => update({ requestId: null })}
|
||||
/>
|
||||
)}
|
||||
{state.jobId !== null && (
|
||||
<PivotChip
|
||||
label={t("adminLogs.pivotJob", { id: state.jobId })}
|
||||
clearLabel={t("adminLogs.clearPivot")}
|
||||
onClear={() => update({ jobId: null })}
|
||||
/>
|
||||
)}
|
||||
{state.runId !== null && (
|
||||
<PivotChip
|
||||
label={t("adminLogs.pivotRun", { id: state.runId })}
|
||||
clearLabel={t("adminLogs.clearPivot")}
|
||||
onClear={() => update({ runId: null })}
|
||||
/>
|
||||
)}
|
||||
<HistoryFrame
|
||||
testId="admin-logs"
|
||||
list={logs}
|
||||
rowCount={rows.length}
|
||||
rowsClassName="font-mono text-2xs"
|
||||
failure={failure && { text: t(`adminLogs.${failure}`), onRetry: retryFailed }}
|
||||
older={
|
||||
olderCursor !== null
|
||||
? { label: t("adminLogs.older"), onClick: loadOlder, disabled: logs.isFetchingNextPage }
|
||||
: null
|
||||
}
|
||||
count={t("adminLogs.count", { count: rows.length })}
|
||||
empty={t("adminLogs.empty")}
|
||||
header={
|
||||
// The page's own band carries the shared filters and stays in reach; this one scrolls.
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("adminLogs.levelLabel")}
|
||||
value={state.group}
|
||||
onChange={(group) => update({ group })}
|
||||
options={GROUPS.map((g) => ({
|
||||
value: g,
|
||||
label: t(`settings.myLog.filters.${g}`),
|
||||
count: groupCount(g),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{failure && (
|
||||
<StateMessage tone="error" compact onRetry={retryFailed} className="border-b border-border">
|
||||
{t(`adminLogs.${failure}`)}
|
||||
</StateMessage>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`font-mono text-2xs ${logs.isPlaceholderData ? "opacity-60" : ""}`}
|
||||
data-testid="admin-logs-lines"
|
||||
aria-busy={logs.isPlaceholderData}
|
||||
>
|
||||
{logs.isError && !logs.data ? (
|
||||
<StateMessage tone="error" onRetry={() => void logs.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : logs.isLoading ? (
|
||||
<LoadingState shape="table" />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
|
||||
{t("adminLogs.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<NavGrid>
|
||||
{rows.map((row, index) => (
|
||||
<LogLine
|
||||
navIndex={index}
|
||||
key={row.id}
|
||||
ts={row.ts}
|
||||
level={row.level}
|
||||
event={row.event ?? "—"}
|
||||
msg={row.msg}
|
||||
title={formatDateTimeSec(row.ts, lang)}
|
||||
open={open === row.id}
|
||||
onToggle={() => setOpen(open === row.id ? null : row.id)}
|
||||
>
|
||||
<RowDetail
|
||||
row={row}
|
||||
account={row.user_id === null ? null : accountLabel(String(row.user_id))}
|
||||
onRequest={(requestId) => pivot({ requestId })}
|
||||
onJob={(jobId) => pivot({ jobId })}
|
||||
onRun={(runId) => pivot({ runId })}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={facetFilters} />
|
||||
{!fold.folded && (
|
||||
<>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.source")}
|
||||
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
|
||||
selected={[state.sources[0] ?? ""]}
|
||||
onChange={([source]) => update({ sources: source ? [source] : [] })}
|
||||
testId="logs-source"
|
||||
/>
|
||||
</LogLine>
|
||||
))}
|
||||
</NavGrid>
|
||||
)}
|
||||
</div>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.event")}
|
||||
options={facetOptions(facets.data?.events, state.event, (v) =>
|
||||
v === "none" ? t("adminLogs.noEvent") : v,
|
||||
)}
|
||||
selected={[state.event ?? ""]}
|
||||
onChange={([event]) => update({ event: event || null })}
|
||||
testId="logs-event"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto flex gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon={<Radio className="h-3.5 w-3.5" />}
|
||||
pressed={polling}
|
||||
onClick={toggleLive}
|
||||
>
|
||||
{t("adminLogs.live")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
// While a filter change loads, the rows on screen are the previous filter's.
|
||||
disabled={rows.length === 0 || logs.isPlaceholderData}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
{t("adminLogs.copy")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
|
||||
<span>{t("adminLogs.count", { count: rows.length })}</span>
|
||||
{olderCursor !== null && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={logs.isFetchingNextPage}
|
||||
onClick={loadOlder}
|
||||
>
|
||||
{t("adminLogs.older")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(state.requestId || state.jobId !== null || state.runId !== null) && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
|
||||
{state.requestId && (
|
||||
<PivotChip
|
||||
label={t("adminLogs.pivotRequest", { id: state.requestId })}
|
||||
clearLabel={t("adminLogs.clearPivot")}
|
||||
onClear={() => update({ requestId: null })}
|
||||
/>
|
||||
)}
|
||||
{state.jobId !== null && (
|
||||
<PivotChip
|
||||
label={t("adminLogs.pivotJob", { id: state.jobId })}
|
||||
clearLabel={t("adminLogs.clearPivot")}
|
||||
onClear={() => update({ jobId: null })}
|
||||
/>
|
||||
)}
|
||||
{state.runId !== null && (
|
||||
<PivotChip
|
||||
label={t("adminLogs.pivotRun", { id: state.runId })}
|
||||
clearLabel={t("adminLogs.clearPivot")}
|
||||
onClear={() => update({ runId: null })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{rows.map((row, index) => (
|
||||
<LogLine
|
||||
navIndex={index}
|
||||
key={row.id}
|
||||
ts={row.ts}
|
||||
level={row.level}
|
||||
event={row.event ?? "—"}
|
||||
msg={row.msg}
|
||||
title={formatDateTimeSec(row.ts, lang)}
|
||||
open={open === row.id}
|
||||
onToggle={() => setOpen(open === row.id ? null : row.id)}
|
||||
>
|
||||
<RowDetail
|
||||
row={row}
|
||||
account={row.user_id === null ? null : accountLabel(String(row.user_id))}
|
||||
onRequest={(requestId) => pivot({ requestId })}
|
||||
onJob={(jobId) => pivot({ jobId })}
|
||||
onRun={(runId) => pivot({ runId })}
|
||||
/>
|
||||
</LogLine>
|
||||
))}
|
||||
</HistoryFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,99 @@ import NavGrid from "./ui/NavGrid";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
|
||||
// The frame of a history list on the activity page: the rows (or the honest state instead of them —
|
||||
// a failed read is an error with a retry, never an empty list), the count, and "Older".
|
||||
// The frame of a history list on the activity page — the runs, the admin actions, the server log:
|
||||
// its own bar (if any), the rows or the honest state instead of them (a failed read is an error with a
|
||||
// retry, never an empty list), a read that failed over rows still on screen, the count, and "Older".
|
||||
// `HistoryFrame` draws it; each list says only what its failure and its "Older" are — the server log's
|
||||
// count its filter facets and stop while Live (AdminLogs), the others not (HistoryList, below).
|
||||
|
||||
/** What a read that failed while rows stayed on screen means, and how to try it again. */
|
||||
export interface FrameFailure {
|
||||
text: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export function HistoryFrame({
|
||||
list,
|
||||
rowCount,
|
||||
header,
|
||||
failure,
|
||||
older,
|
||||
count,
|
||||
empty,
|
||||
rowsClassName = "",
|
||||
testId,
|
||||
children,
|
||||
}: {
|
||||
/** The list's read. */
|
||||
list: {
|
||||
data?: unknown;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
isPlaceholderData: boolean;
|
||||
refetch: () => unknown;
|
||||
};
|
||||
rowCount: number;
|
||||
/** The list's own bar above the rows. */
|
||||
header?: ReactNode;
|
||||
failure: FrameFailure | null;
|
||||
/** "Older", when there is an older page to ask for. */
|
||||
older: { label: string; onClick: () => void; disabled: boolean } | null;
|
||||
/** "N runs shown", for the footer. */
|
||||
count: string;
|
||||
/** Shown when the filters match nothing. */
|
||||
empty: string;
|
||||
rowsClassName?: string;
|
||||
testId?: string;
|
||||
/** The rows. */
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div data-testid={testId} className="overflow-clip rounded-xl border border-border bg-bg">
|
||||
{header}
|
||||
{failure && (
|
||||
<StateMessage
|
||||
tone="error"
|
||||
compact
|
||||
onRetry={failure.onRetry}
|
||||
className="border-b border-border"
|
||||
>
|
||||
{failure.text}
|
||||
</StateMessage>
|
||||
)}
|
||||
<div
|
||||
className={`${rowsClassName} ${list.isPlaceholderData ? "opacity-60" : ""}`}
|
||||
data-testid={testId && `${testId}-lines`}
|
||||
aria-busy={list.isPlaceholderData}
|
||||
>
|
||||
{list.isError && !list.data ? (
|
||||
<StateMessage tone="error" onRetry={() => void list.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : list.isLoading ? (
|
||||
<LoadingState shape="table" />
|
||||
) : rowCount === 0 ? (
|
||||
<StateMessage compact className="font-sans">
|
||||
{empty}
|
||||
</StateMessage>
|
||||
) : (
|
||||
<NavGrid>{children}</NavGrid>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
|
||||
<span>{count}</span>
|
||||
{older && (
|
||||
<Button variant="outline" size="xs" disabled={older.disabled} onClick={older.onClick}>
|
||||
{older.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A history list read through `useHistoryList` (the runs, the admin actions). */
|
||||
export default function HistoryList<T>({
|
||||
state,
|
||||
empty,
|
||||
@@ -26,59 +116,29 @@ export default function HistoryList<T>({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { list, rows, hasOlder } = state;
|
||||
const fetchOlder = () => void list.fetchNextPage(nextPageOnClick(list.isFetchingNextPage));
|
||||
// A read that failed while rows were on screen keeps them, and says what the failure means.
|
||||
const failure = !list.data
|
||||
const failure: FrameFailure | null = !list.data
|
||||
? null
|
||||
: list.isFetchNextPageError
|
||||
? "olderFailed"
|
||||
? { text: t("activity.olderFailed"), onRetry: fetchOlder }
|
||||
: list.isError
|
||||
? "stale"
|
||||
? { text: t("activity.stale"), onRetry: () => void list.refetch() }
|
||||
: null;
|
||||
return (
|
||||
<div className="overflow-clip rounded-xl border border-border bg-bg">
|
||||
{failure && (
|
||||
<StateMessage
|
||||
tone="error"
|
||||
compact
|
||||
onRetry={() =>
|
||||
failure === "olderFailed"
|
||||
? void list.fetchNextPage(nextPageOnClick(list.isFetchingNextPage))
|
||||
: void list.refetch()
|
||||
}
|
||||
className="border-b border-border"
|
||||
>
|
||||
{failure === "olderFailed" ? t("activity.olderFailed") : t("activity.stale")}
|
||||
</StateMessage>
|
||||
)}
|
||||
<div
|
||||
className={list.isPlaceholderData ? "opacity-60" : ""}
|
||||
aria-busy={list.isPlaceholderData}
|
||||
>
|
||||
{list.isError && !list.data ? (
|
||||
<StateMessage tone="error" onRetry={() => void list.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : list.isLoading ? (
|
||||
<LoadingState shape="table" />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted">{empty}</p>
|
||||
) : (
|
||||
<NavGrid>{children}</NavGrid>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
|
||||
<span>{count}</span>
|
||||
{hasOlder && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={list.isFetchingNextPage}
|
||||
onClick={() => void list.fetchNextPage(nextPageOnClick(list.isFetchingNextPage))}
|
||||
>
|
||||
{t("activity.older")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<HistoryFrame
|
||||
list={list}
|
||||
rowCount={rows.length}
|
||||
failure={failure}
|
||||
older={
|
||||
hasOlder
|
||||
? { label: t("activity.older"), onClick: fetchOlder, disabled: list.isFetchingNextPage }
|
||||
: null
|
||||
}
|
||||
count={count}
|
||||
empty={empty}
|
||||
>
|
||||
{children}
|
||||
</HistoryFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import type { ReactNode } from "react";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// One dense row of a history list on the activity page — a background run, an admin action: its
|
||||
// cells on one line, and the row opens in place to its details, as a server log line does
|
||||
// (./LogLine). Inside a NavGrid the keyboard walks the rows (`j`/`k`) and Enter opens one.
|
||||
// One dense row of a history list — a background run, an admin action, a log line (./LogLine draws
|
||||
// its lines through this): its cells on one line, and the row opens in place to its details. One frame
|
||||
// for all of them, so a row opens, announces itself and takes the keyboard the same on every tab.
|
||||
// Inside a NavGrid the keyboard walks the rows (`j`/`k`) and Enter opens one.
|
||||
|
||||
export default function HistoryRow({
|
||||
cells,
|
||||
@@ -15,6 +16,7 @@ export default function HistoryRow({
|
||||
open,
|
||||
onToggle,
|
||||
title,
|
||||
line = false,
|
||||
children,
|
||||
}: {
|
||||
/** The row's cells, laid out by `grid`. */
|
||||
@@ -28,6 +30,8 @@ export default function HistoryRow({
|
||||
onToggle: () => void;
|
||||
/** The row button's tooltip — the full date. */
|
||||
title?: string;
|
||||
/** A log line: tighter, its cells on one text baseline, its size the (monospace) list's own. */
|
||||
line?: boolean;
|
||||
/** The detail shown while the row is open. */
|
||||
children: ReactNode;
|
||||
}) {
|
||||
@@ -43,13 +47,17 @@ export default function HistoryRow({
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
title={title}
|
||||
className={`grid w-full items-center gap-x-2 px-3 py-1.5 text-left text-xs touch-target pointer-coarse:content-center ${grid} ${
|
||||
open ? "bg-accent/10" : ""
|
||||
}`}
|
||||
className={`grid w-full gap-x-2 px-3 text-left touch-target pointer-coarse:content-center ${
|
||||
line ? "items-baseline py-1" : "items-center py-1.5 text-xs"
|
||||
} ${grid} ${open ? "bg-accent/10" : ""}`}
|
||||
>
|
||||
{cells}
|
||||
</Pressable>
|
||||
{open && <div className="bg-accent/5 px-3 py-2 text-xs text-muted">{children}</div>}
|
||||
{open && (
|
||||
<div className={`bg-accent/5 px-3 py-2 text-muted ${line ? "" : "text-xs"}`}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { Level } from "../lib/log";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import HistoryRow from "./HistoryRow";
|
||||
import { LEVEL_TAG, logClock } from "./logLineParts";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
|
||||
// diagnostics console (the server's stored log): time, level, event code, message, and the line opens
|
||||
// in place to whatever detail its screen has. One component, so a level reads the same on both.
|
||||
// Inside a NavGrid the keyboard walks the lines (`j`/`k`) and Enter opens one, as a click does.
|
||||
// The row frame is the history lists' (./HistoryRow): inside a NavGrid the keyboard walks the lines
|
||||
// (`j`/`k`) and Enter opens one, as a click does.
|
||||
|
||||
export default function LogLine({
|
||||
ts,
|
||||
@@ -36,29 +36,26 @@ export default function LogLine({
|
||||
}) {
|
||||
const tag = LEVEL_TAG[level];
|
||||
return (
|
||||
<div
|
||||
className="border-b border-border/50 last:border-b-0"
|
||||
{...navItemAttrs(navIndex, `${event} ${msg}`, { inset: true })}
|
||||
<HistoryRow
|
||||
line
|
||||
grid="grid-cols-[6.5em_3.5em_minmax(0,1fr)] sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)]"
|
||||
navIndex={navIndex}
|
||||
navText={`${event} ${msg}`}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
title={title}
|
||||
cells={
|
||||
<>
|
||||
<span className="tabular-nums text-muted">{logClock(ts)}</span>
|
||||
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
|
||||
<span className="truncate">{event}</span>
|
||||
{/* A phone has no room for a fourth column: the message takes a line of its own under the
|
||||
code, full width — sharing the code's line left it a few characters. */}
|
||||
<span className="col-span-3 truncate sm:col-span-1">{msg}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Pressable
|
||||
selectable
|
||||
layer={!open}
|
||||
data-nav-open
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
title={title}
|
||||
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-x-2 px-3 py-1 touch-target pointer-coarse:content-center ${
|
||||
open ? "bg-accent/10" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="tabular-nums text-muted">{logClock(ts)}</span>
|
||||
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
|
||||
<span className="truncate">{event}</span>
|
||||
{/* A phone has no room for a fourth column: the message takes a line of its own under the
|
||||
code, full width — sharing the code's line left it a few characters. */}
|
||||
<span className="col-span-3 truncate sm:col-span-1">{msg}</span>
|
||||
</Pressable>
|
||||
{open && <div className="bg-accent/5 px-3 py-2 text-muted">{children}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</HistoryRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ import { useBackToClose } from "../lib/history";
|
||||
import { PageHeading } from "./ui/Headings";
|
||||
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { LS, setAccountRaw } from "../lib/storage";
|
||||
import { PAGE_KEY, START_ON_HOME } from "./navigationInit";
|
||||
|
||||
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
|
||||
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
|
||||
@@ -239,6 +241,35 @@ describe("NavigationProvider · Back", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries an old server-log link's account, text and range onto the page's own filters", () => {
|
||||
window.history.replaceState(null, "", "/?page=logs&lu=3&lq=boom&lwin=1h");
|
||||
render();
|
||||
const url = new URLSearchParams(window.location.search);
|
||||
expect([url.get("at"), url.get("au"), url.get("aq"), url.get("aw")]).toEqual([
|
||||
"logs",
|
||||
"3",
|
||||
"boom",
|
||||
"1h",
|
||||
]);
|
||||
// …and the dead keys leave the address bar instead of riding along to every page after.
|
||||
expect(["lu", "lq", "lwin"].filter((k) => url.has(k))).toEqual([]);
|
||||
});
|
||||
|
||||
it("an old server-log link without a range keeps the one it meant", () => {
|
||||
window.history.replaceState(null, "", "/?page=logs");
|
||||
render();
|
||||
expect(new URLSearchParams(window.location.search).get("aw")).toBe("24h");
|
||||
});
|
||||
|
||||
it("a remembered retired page opens its tab", () => {
|
||||
setAccountRaw(LS.startOnHome, START_ON_HOME.off);
|
||||
setAccountRaw(PAGE_KEY, "logs");
|
||||
window.history.replaceState(null, "", "/");
|
||||
const { result } = render();
|
||||
expect(result.current.page).toBe("activity");
|
||||
expect(new URLSearchParams(window.location.search).get("at")).toBe("logs");
|
||||
});
|
||||
|
||||
it("opens a retired page's tab when Back lands on its entry", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setPage("plex"));
|
||||
|
||||
@@ -6,7 +6,7 @@ import { resolvePage, retiredParams, urlWithParams, type Page } from "../lib/url
|
||||
import { ACTIVITY_KEYS } from "../lib/activityUrlState";
|
||||
import { pageTitleKey } from "../lib/pageMeta";
|
||||
import { useDocumentObjectTitle } from "../lib/documentTitle";
|
||||
import { setAccountRaw } from "../lib/storage";
|
||||
import { getAccountRaw, setAccountRaw } from "../lib/storage";
|
||||
import {
|
||||
backStaysInDocument,
|
||||
closeOverlaysThenNavigate,
|
||||
@@ -193,9 +193,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
|
||||
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
|
||||
// ?chan / ?v / ?pl / filter params the loaders already read.
|
||||
// A retired page's link — or the remembered last page, when that is what opened — also opens its
|
||||
// part of the page that replaced it (an `at` tab).
|
||||
const linked = new URLSearchParams(window.location.search).get("page");
|
||||
const remembered = getAccountRaw(PAGE_KEY);
|
||||
const url = urlWithParams({
|
||||
// A retired page's link also opens its part of the page that replaced it (an `at` tab).
|
||||
...retiredParams(new URLSearchParams(window.location.search).get("page")),
|
||||
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
|
||||
page: mountPage === "feed" ? null : mountPage,
|
||||
});
|
||||
if (st._yt && st._ytScrape) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { RUN_STATUSES } from "../lib/activityUrlState";
|
||||
import { vocabKey } from "../lib/vocab";
|
||||
|
||||
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
|
||||
@@ -72,6 +74,15 @@ const jobs = [
|
||||
const enumValues = (file: string) =>
|
||||
[...backend(file).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm)].map((m) => m[1]!);
|
||||
const auditActions = enumValues("audit.py");
|
||||
// A background run's words (backend/app/runstatus.py): its status and what started it.
|
||||
const runEnum = (name: string) =>
|
||||
[
|
||||
...(
|
||||
backend("runstatus.py").split(`class ${name}(StrEnum):`)[1]?.split("\nclass ")[0] ?? ""
|
||||
).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm),
|
||||
].map((m) => m[1]!);
|
||||
const runStatuses = runEnum("RunStatus");
|
||||
const runTriggers = runEnum("RunTrigger");
|
||||
const quotaActions = enumValues("quota.py");
|
||||
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from
|
||||
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict
|
||||
@@ -260,6 +271,8 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"]));
|
||||
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
|
||||
expect(quotaActions.length).toBeGreaterThan(0);
|
||||
expect(runStatuses).toEqual(expect.arrayContaining(["running", "interrupted"]));
|
||||
expect(runTriggers).toEqual(expect.arrayContaining(["scheduled", "ad_hoc"]));
|
||||
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"]));
|
||||
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
||||
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
||||
@@ -288,10 +301,23 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
|
||||
});
|
||||
|
||||
it("the run list's status chips are the server's run statuses", () => {
|
||||
expect([...RUN_STATUSES].sort()).toEqual([...runStatuses].sort());
|
||||
});
|
||||
|
||||
for (const lang of ["en", "hu"]) {
|
||||
const keys = keysOf(lang);
|
||||
const missing = (wanted: string[]) => wanted.filter((k) => !keys.has(k));
|
||||
|
||||
it(`${lang}: every run status and trigger`, () => {
|
||||
expect(
|
||||
missing([
|
||||
...runStatuses.flatMap((s) => [`activity.runs.status.${s}`, `scheduler.dot.${s}`]),
|
||||
...runTriggers.map((s) => `scheduler.trigger.${s}`),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${lang}: every config key and group`, () => {
|
||||
expect(
|
||||
missing([
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"24h": "24 hours",
|
||||
"7d": "7 days",
|
||||
"30d": "30 days",
|
||||
"90d": "90 days"
|
||||
"90d": "90 days",
|
||||
"all": "All"
|
||||
},
|
||||
"account": "Account",
|
||||
"any": "Any",
|
||||
@@ -26,7 +27,8 @@
|
||||
"older": "Load older",
|
||||
"stale": "Couldn't refresh — what you see may be out of date",
|
||||
"olderFailed": "Couldn't load the older rows",
|
||||
"countsFailed": "Couldn't load the filter counts",
|
||||
"filtersFailed": "Couldn't load the filter choices: the menus may be missing some.",
|
||||
"accountsFailed": "Couldn't load the accounts for the account filter.",
|
||||
"runs": {
|
||||
"job": "Job",
|
||||
"statusLabel": "Filter by status",
|
||||
@@ -57,10 +59,15 @@
|
||||
"action": "Action",
|
||||
"target": "Target",
|
||||
"requestLines": "Its request's log lines →",
|
||||
"noRequestLines": "Its request's log lines are no longer kept",
|
||||
"count_one": "{{count}} action shown · newest first",
|
||||
"count_other": "{{count}} actions shown · newest first"
|
||||
},
|
||||
"quota": {
|
||||
"total": "Spend over {{window}}: {{units}} units"
|
||||
"daily": "Spend per day",
|
||||
"total_one": "Spend over {{window}}: {{units}} unit",
|
||||
"total_other": "Spend over {{window}}: {{units}} units",
|
||||
"totalAll_one": "Spend over all kept history: {{units}} unit",
|
||||
"totalAll_other": "Spend over all kept history: {{units}} units"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"introAfter": " is shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.",
|
||||
"loading": "Loading…",
|
||||
"noUsageInRange": "No usage in this range.",
|
||||
"dailyTooltip": "{{day}}: {{units}} units",
|
||||
"system": "System (background)",
|
||||
"sync": {
|
||||
"myStatus": "My sync status",
|
||||
@@ -41,5 +40,7 @@
|
||||
"fullHistoryRequested_one": "Full history requested for {{count}} channel",
|
||||
"fullHistoryRequested_other": "Full history requested for {{count}} channels",
|
||||
"fullHistoryFailed": "Couldn't request full history"
|
||||
}
|
||||
},
|
||||
"dailyTooltip_one": "{{day}}: {{units}} unit",
|
||||
"dailyTooltip_other": "{{day}}: {{units}} units"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"24h": "24 óra",
|
||||
"7d": "7 nap",
|
||||
"30d": "30 nap",
|
||||
"90d": "90 nap"
|
||||
"90d": "90 nap",
|
||||
"all": "Mind"
|
||||
},
|
||||
"account": "Fiók",
|
||||
"any": "Bármelyik",
|
||||
@@ -26,7 +27,8 @@
|
||||
"older": "Régebbiek",
|
||||
"stale": "Nem sikerült frissíteni — amit látsz, elavult lehet",
|
||||
"olderFailed": "Nem sikerült betölteni a régebbi sorokat",
|
||||
"countsFailed": "Nem sikerült betölteni a szűrők darabszámait",
|
||||
"filtersFailed": "Nem sikerült betölteni a szűrők választékát: a menükből hiányozhatnak elemek.",
|
||||
"accountsFailed": "Nem sikerült betölteni a fiókokat a fiókszűrőhöz.",
|
||||
"runs": {
|
||||
"job": "Feladat",
|
||||
"statusLabel": "Szűrés állapotra",
|
||||
@@ -57,10 +59,15 @@
|
||||
"action": "Művelet",
|
||||
"target": "Cél",
|
||||
"requestLines": "A kérés naplósorai →",
|
||||
"noRequestLines": "A kérés naplósorai már nincsenek meg",
|
||||
"count_one": "{{count}} művelet látszik · a legújabb elöl",
|
||||
"count_other": "{{count}} művelet látszik · a legújabb elöl"
|
||||
},
|
||||
"quota": {
|
||||
"total": "Felhasználás ({{window}}): {{units}} egység"
|
||||
"daily": "Felhasználás naponta",
|
||||
"total_one": "Felhasználás ({{window}}): {{units}} egység",
|
||||
"total_other": "Felhasználás ({{window}}): {{units}} egység",
|
||||
"totalAll_one": "Felhasználás (a teljes megőrzött idő): {{units}} egység",
|
||||
"totalAll_other": "Felhasználás (a teljes megőrzött idő): {{units}} egység"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"introAfter": " a megosztott háttérmunka (ütemezett behúzás, dúsítás, újraszinkronizálás), amely nem köthető egy személyhez.",
|
||||
"loading": "Betöltés…",
|
||||
"noUsageInRange": "Nincs használat ebben az időszakban.",
|
||||
"dailyTooltip": "{{day}}: {{units}} egység",
|
||||
"system": "Rendszer (háttér)",
|
||||
"sync": {
|
||||
"myStatus": "Saját szinkronizálási állapot",
|
||||
@@ -41,5 +40,7 @@
|
||||
"fullHistoryRequested_one": "Teljes előzmény kérve {{count}} csatornához",
|
||||
"fullHistoryRequested_other": "Teljes előzmény kérve {{count}} csatornához",
|
||||
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni"
|
||||
}
|
||||
},
|
||||
"dailyTooltip_one": "{{day}}: {{units}} egység",
|
||||
"dailyTooltip_other": "{{day}}: {{units}} egység"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
activityParamUpdates,
|
||||
DEFAULT_ACTIVITY_STATE,
|
||||
sinceOf,
|
||||
ACTIVITY_WINDOWS,
|
||||
type ActivityUrlState,
|
||||
} from "./activityUrlState";
|
||||
|
||||
@@ -54,4 +55,11 @@ describe("activityUrlState", () => {
|
||||
expect(sinceOf("24h", now)).toBe("2026-10-17T12:00:00.000Z");
|
||||
expect(sinceOf("90d", now)).toBe("2026-07-20T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("offers everything kept, past the longest range", () => {
|
||||
// Runs and admin actions are kept 180 days by default, or for ever: a 90-day cap hid the rest.
|
||||
expect(ACTIVITY_WINDOWS[ACTIVITY_WINDOWS.length - 1]).toBe("all");
|
||||
expect(activityParamsToState(new URLSearchParams("aw=all")).window).toBe("all");
|
||||
expect(sinceOf("all")).toBe("1970-01-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
// each tab's own (the run list's job and status, the audit list's action). The server log's own
|
||||
// filters keep their `l…` keys (./logsUrlState). setPage clears all of them (ACTIVITY_KEYS); the page
|
||||
// re-mirrors them while it is open.
|
||||
import type { RunStatus } from "./api";
|
||||
import { LOGS_KEYS } from "./logsUrlState";
|
||||
|
||||
export type ActivityTab = "runs" | "admin" | "logs" | "quota";
|
||||
export type ActivityWindow = "1h" | "24h" | "7d" | "30d" | "90d";
|
||||
/** The run statuses the list can narrow to (the API's `RunStatus`). */
|
||||
export type RunStatusFilter = "running" | "ok" | "error" | "skipped" | "interrupted";
|
||||
export type ActivityWindow = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
|
||||
/** The run statuses the list can narrow to: the API's `RunStatus` (held by backendIds.contract.test). */
|
||||
export type RunStatusFilter = RunStatus;
|
||||
|
||||
export interface ActivityUrlState {
|
||||
tab: ActivityTab;
|
||||
@@ -24,7 +25,9 @@ export interface ActivityUrlState {
|
||||
}
|
||||
|
||||
export const ACTIVITY_TABS: readonly ActivityTab[] = ["runs", "admin", "logs", "quota"];
|
||||
export const ACTIVITY_WINDOWS: readonly ActivityWindow[] = ["1h", "24h", "7d", "30d", "90d"];
|
||||
// `all` = everything still kept: runs and admin actions are kept 180 days by default (0 = for ever),
|
||||
// longer than any fixed range.
|
||||
export const ACTIVITY_WINDOWS: readonly ActivityWindow[] = ["1h", "24h", "7d", "30d", "90d", "all"];
|
||||
export const RUN_STATUSES: readonly RunStatusFilter[] = [
|
||||
"error",
|
||||
"interrupted",
|
||||
@@ -33,12 +36,13 @@ export const RUN_STATUSES: readonly RunStatusFilter[] = [
|
||||
"ok",
|
||||
];
|
||||
|
||||
const WINDOW_HOURS: Record<ActivityWindow, number> = {
|
||||
const WINDOW_HOURS: Record<ActivityWindow, number | null> = {
|
||||
"1h": 1,
|
||||
"24h": 24,
|
||||
"7d": 24 * 7,
|
||||
"30d": 24 * 30,
|
||||
"90d": 24 * 90,
|
||||
all: null,
|
||||
};
|
||||
|
||||
export const DEFAULT_ACTIVITY_STATE: ActivityUrlState = {
|
||||
@@ -92,7 +96,9 @@ export function activityParamUpdates(state: ActivityUrlState): Record<string, st
|
||||
};
|
||||
}
|
||||
|
||||
/** Where the window starts, as the API takes it. */
|
||||
/** Where the window starts, as the API takes it: `all` from the epoch (the quota read would default
|
||||
* to its last 30 days without one). */
|
||||
export function sinceOf(window: ActivityWindow, now = Date.now()): string {
|
||||
return new Date(now - WINDOW_HOURS[window] * 3_600_000).toISOString();
|
||||
const hours = WINDOW_HOURS[window];
|
||||
return new Date(hours === null ? 0 : now - hours * 3_600_000).toISOString();
|
||||
}
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface AuditRow {
|
||||
after: Record<string, unknown> | null;
|
||||
/** The request the change was made in — its log lines are one pivot away. Null on older rows. */
|
||||
request_id: string | null;
|
||||
/** How many of that request's log lines are still kept (they go after days, the row after months). */
|
||||
log_lines: number;
|
||||
}
|
||||
|
||||
/** A page of a history list (audit log, runs), newest first; `next_cursor` continues it. */
|
||||
@@ -142,7 +144,8 @@ export interface RunRow {
|
||||
summary: string | null;
|
||||
result: JobResult | null;
|
||||
error: string | null;
|
||||
failure: { code: string; [key: string]: unknown } | null;
|
||||
/** A failure the job named (`progress.JobFailed`): its reason, and the values it is said with. */
|
||||
failure: { failure: string; [key: string]: unknown } | null;
|
||||
quota_units: number;
|
||||
/** How many stored log lines the run left (WARNING and up only). */
|
||||
log_lines: number;
|
||||
@@ -249,8 +252,10 @@ function queryString(values: object): string {
|
||||
export type JobResult = { [key: string]: number | string | JobResult };
|
||||
|
||||
/** How a run ended — the server's `RunStatus` (app/runstatus.py) minus `running`. `interrupted`:
|
||||
* the process stopped mid-run (a deploy, a crash), and its next start closed the row. */
|
||||
* the process stopped mid-run (a deploy, a crash), and its next start closed the row. The values are
|
||||
* held to the server's by backendIds.contract.test (through RUN_STATUSES, typed as `RunStatus`). */
|
||||
export type RunOutcome = "ok" | "error" | "skipped" | "interrupted";
|
||||
export type RunStatus = RunOutcome | "running";
|
||||
|
||||
/** One past run, as the row's history dots show it. */
|
||||
export interface RunBrief {
|
||||
|
||||
@@ -25,6 +25,7 @@ const row = (over: Partial<AuditRow>): AuditRow => ({
|
||||
before: null,
|
||||
after: null,
|
||||
request_id: null,
|
||||
log_lines: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
|
||||
@@ -37,9 +37,10 @@ it("index.css draws an inset item's ring inside, after the outside rule", () =>
|
||||
|
||||
// The items that sit flush, by their source: a new one of the kind should be added here.
|
||||
const src = (p: string) => readFileSync(new URL(p, import.meta.url), "utf8");
|
||||
it.each([["../components/LogLine.tsx", /navItemAttrs\(navIndex, [^)]*\{ inset: true \}\)/]])(
|
||||
"%s asks for the inside ring",
|
||||
(file, re) => {
|
||||
expect(src(file)).toMatch(re);
|
||||
},
|
||||
);
|
||||
// The history rows (runs, admin actions) and the log lines share one frame, HistoryRow.
|
||||
it.each([
|
||||
["../components/HistoryRow.tsx", /navItemAttrs\(navIndex, [^)]*\{ inset: true \}\)/],
|
||||
["../components/LogLine.tsx", /<HistoryRow\s/],
|
||||
])("%s asks for the inside ring", (file, re) => {
|
||||
expect(src(file)).toMatch(re);
|
||||
});
|
||||
|
||||
@@ -87,8 +87,12 @@ export const qk = {
|
||||
adminInvites: () => ["admin-invites"] as const,
|
||||
adminPendingAccess: () => ["admin-pending-access"] as const,
|
||||
adminConfig: () => ["admin-config"] as const,
|
||||
adminAudit: (filters: object) => ["admin-audit", filters] as const,
|
||||
adminAuditFacets: (filters: object) => ["admin-audit-facets", filters] as const,
|
||||
adminAudit: (filters?: object) =>
|
||||
filters !== undefined ? (["admin-audit", filters] as const) : (["admin-audit"] as const),
|
||||
adminAuditFacets: (filters?: object) =>
|
||||
filters !== undefined
|
||||
? (["admin-audit-facets", filters] as const)
|
||||
: (["admin-audit-facets"] as const),
|
||||
adminRuns: (filters: object) => ["admin-runs", filters] as const,
|
||||
adminRunFacets: (filters: object) => ["admin-run-facets", filters] as const,
|
||||
// The console's filters are part of the key; `since` is not (it moves with the clock) — the
|
||||
|
||||
@@ -142,15 +142,32 @@ function isPage(p: string | null | undefined): p is Page {
|
||||
}
|
||||
|
||||
// A page this build no longer has, the page that took its place, and the params that open its part
|
||||
// of that page. A bookmark, the remembered last page and an old history entry can still name one; all
|
||||
// three resolve through `resolvePage`, so a retired page lands where its content went instead of on
|
||||
// the feed, and the navigation provider writes `params` into the address bar as it rewrites the page.
|
||||
const RETIRED: Readonly<Record<string, { page: Page; params?: Record<string, string> }>> = {
|
||||
// of that page — from the old link's own params. A bookmark, the remembered last page and an old
|
||||
// history entry can still name one; all three resolve through `resolvePage`, so a retired page lands
|
||||
// where its content went instead of on the feed, and the navigation provider writes `params` into the
|
||||
// address bar as it rewrites the page (a null clears a key the old page owned).
|
||||
type Updates = Record<string, string | null>;
|
||||
const RETIRED: Readonly<
|
||||
Record<string, { page: Page; params?: (old: URLSearchParams) => Updates }>
|
||||
> = {
|
||||
// The download queue became the tray; what it downloaded lives in Files.
|
||||
downloads: { page: "files" },
|
||||
// The audit log and the server log became tabs of the activity page (its `at` key).
|
||||
audit: { page: "activity", params: { at: "admin" } },
|
||||
logs: { page: "activity", params: { at: "logs" } },
|
||||
audit: { page: "activity", params: () => ({ at: "admin" }) },
|
||||
// The server log's own account, text and range became the page's shared filters; a link without
|
||||
// a range meant the log's default then, 24 hours.
|
||||
logs: {
|
||||
page: "activity",
|
||||
params: (old) => ({
|
||||
at: "logs",
|
||||
au: old.get("lu"),
|
||||
aq: old.get("lq"),
|
||||
aw: old.get("lwin") ?? "24h",
|
||||
lu: null,
|
||||
lq: null,
|
||||
lwin: null,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const retired = (p: string | null | undefined) =>
|
||||
@@ -162,9 +179,13 @@ export function resolvePage(p: string | null | undefined): Page | null {
|
||||
return retired(p)?.page ?? null;
|
||||
}
|
||||
|
||||
/** The params that open a retired page's part of its successor (none for a live page). */
|
||||
export function retiredParams(p: string | null | undefined): Record<string, string> {
|
||||
return isPage(p) ? {} : (retired(p)?.params ?? {});
|
||||
/** The params that open a retired page's part of its successor, read from the old link's `params`
|
||||
* (none for a live page). */
|
||||
export function retiredParams(
|
||||
p: string | null | undefined,
|
||||
params = new URLSearchParams(window.location.search),
|
||||
): Updates {
|
||||
return isPage(p) ? {} : (retired(p)?.params?.(params) ?? {});
|
||||
}
|
||||
|
||||
export function readPage(): Page {
|
||||
|
||||
Reference in New Issue
Block a user