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 () => {
+17 -5
View File
@@ -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} />
)}
+24 -14
View File
@@ -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>
);
}
+18 -2
View File
@@ -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)}%` }}
+15 -3
View File
@@ -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" };
}
+135 -149
View File
@@ -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>
);
}
+110 -50
View File
@@ -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>
);
}
+15 -7
View File
@@ -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>
);
}
+23 -26
View File
@@ -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([
+10 -3
View File
@@ -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"
}
}
+3 -2
View File
@@ -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"
}
+10 -3
View File
@@ -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"
}
}
+3 -2
View File
@@ -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");
});
});
+13 -7
View File
@@ -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();
}
+7 -2
View File
@@ -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 {
+1
View File
@@ -25,6 +25,7 @@ const row = (over: Partial<AuditRow>): AuditRow => ({
before: null,
after: null,
request_id: null,
log_lines: 0,
...over,
});
+7 -6
View File
@@ -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);
});
+6 -2
View File
@@ -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
+30 -9
View File
@@ -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 {