feat(activity): one Activity page — runs, admin actions, server log and quota under one filter bar
The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.
Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.
Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
This commit is contained in:
@@ -31,7 +31,7 @@ async function violations(page: Page): Promise<string[]> {
|
||||
|
||||
test.describe("accessibility (axe)", () => {
|
||||
const RAIL = ["feed", "channels", "playlists", "files", "stats", "settings"];
|
||||
const ADMIN = ["scheduler", "config", "users", "audit"];
|
||||
const ADMIN = ["scheduler", "config", "users", "activity"];
|
||||
|
||||
for (const id of RAIL) {
|
||||
test(`page: ${id}`, async ({ page }) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ test.describe("page switching", () => {
|
||||
// and it failed on every run for a while without anyone seeing it, because e2e was in no
|
||||
// required gate (it is now part of `release`).
|
||||
const RAIL = ["channels", "playlists", "files", "stats", "settings", "feed"];
|
||||
const ADMIN = ["scheduler", "config", "users", "audit"];
|
||||
const ADMIN = ["scheduler", "config", "users", "activity"];
|
||||
|
||||
test("each rail item activates its page", async ({ page }) => {
|
||||
await openApp(page);
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The activity page around its run and audit lists (the server-log tab has its own file): the view
|
||||
// is the URL — a link narrows the runs to one job, a status chip narrows them further; a run opens
|
||||
// to its log lines and an admin action to its request's, each a new history entry that Back undoes;
|
||||
// a link to the retired audit page opens the admin tab; 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { AuditQuery, AuditRow, HistoryPage, LogQuery, RunQuery, RunRow } from "../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const runsMock = vi.fn<(q: RunQuery, before: string | null) => Promise<HistoryPage<RunRow>>>();
|
||||
const auditMock = vi.fn<(q: AuditQuery, before: string | null) => Promise<HistoryPage<AuditRow>>>();
|
||||
const logsMock = vi.fn<(q: LogQuery, before: string | null) => Promise<unknown>>();
|
||||
const quotaMock = vi.fn<(since: string, user: string | null) => Promise<unknown>>();
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminRuns: (q: RunQuery, before: string | null) => runsMock(q, before),
|
||||
adminRunFacets: () => Promise.resolve({ jobs: [], statuses: [], total: 0 }),
|
||||
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" }]),
|
||||
adminQuota: (since: string, user: string | null) => quotaMock(since, user),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import Activity from "./Activity";
|
||||
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
|
||||
|
||||
// The filter bar lives in the toolbar, which portals into the shell's band: give it one.
|
||||
const slot = document.body.appendChild(document.createElement("div"));
|
||||
|
||||
const RUN: RunRow = {
|
||||
id: 41,
|
||||
job: "backfill",
|
||||
trigger: "manual",
|
||||
actor_id: 1,
|
||||
target: null,
|
||||
target_title: null,
|
||||
status: "error",
|
||||
started_at: "2026-10-18T02:31:04Z",
|
||||
finished_at: "2026-10-18T02:31:45Z",
|
||||
summary: null,
|
||||
result: null,
|
||||
error: "quotaExceeded",
|
||||
failure: null,
|
||||
quota_units: 212,
|
||||
log_lines: 9,
|
||||
};
|
||||
|
||||
const ACTION: AuditRow = {
|
||||
id: 5,
|
||||
created_at: "2026-10-18T02:27:00Z",
|
||||
actor_id: 1,
|
||||
action: "scheduler_interval",
|
||||
target_type: "job",
|
||||
target_id: "rss_poll",
|
||||
summary: "rss_poll → 25 min/run",
|
||||
before: null,
|
||||
after: { interval_minutes: 25 },
|
||||
request_id: "0efdd960c9ad1977",
|
||||
};
|
||||
|
||||
const page = <T,>(items: T[]): HistoryPage<T> => ({
|
||||
items,
|
||||
accounts: { "1": "admin@example.com" },
|
||||
next_cursor: null,
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PageToolbarSlot.Provider value={slot}>
|
||||
<Activity />
|
||||
</PageToolbarSlot.Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const url = () => new URLSearchParams(window.location.search);
|
||||
const rowButton = (text: RegExp) =>
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.hasAttribute("aria-expanded") && text.test(b.textContent ?? ""))!;
|
||||
|
||||
beforeEach(() => {
|
||||
runsMock.mockResolvedValue(page([RUN]));
|
||||
auditMock.mockResolvedValue(page([ACTION]));
|
||||
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
|
||||
quotaMock.mockResolvedValue({ rows: [], daily: [] });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Activity", () => {
|
||||
it("a link narrows the runs to one job, and a status chip narrows them further", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity&rjob=backfill");
|
||||
renderPage();
|
||||
await waitFor(() =>
|
||||
expect(runsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ job: "backfill", status: undefined, since: expect.any(String) }),
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /activity\.runs\.status\.error/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(runsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ job: "backfill", status: "error" }),
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect([url().get("rjob"), url().get("rst")]).toEqual(["backfill", "error"]);
|
||||
});
|
||||
|
||||
it("a run opens to its log lines, and Back returns to the runs", async () => {
|
||||
window.history.replaceState({ sfPage: "activity" }, "", "/?page=activity&aw=24h&aq=quota");
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByText("quotaExceeded")).toBeTruthy());
|
||||
fireEvent.click(rowButton(/quotaExceeded/));
|
||||
fireEvent.click(screen.getByRole("button", { name: "activity.runs.logLines" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ run_id: 41, q: undefined }),
|
||||
null,
|
||||
),
|
||||
);
|
||||
// Every other filter is off, and the range is the log's whole retention.
|
||||
expect([url().get("at"), url().get("lrun"), url().get("aw"), url().get("aq")]).toEqual([
|
||||
"logs",
|
||||
"41",
|
||||
"90d",
|
||||
null,
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
const landed = new Promise((resolve) =>
|
||||
window.addEventListener("popstate", resolve, { once: true }),
|
||||
);
|
||||
window.history.back();
|
||||
await landed;
|
||||
});
|
||||
await waitFor(() => expect(url().get("at")).toBeNull());
|
||||
expect([url().get("aw"), url().get("aq"), url().get("lrun")]).toEqual(["24h", "quota", null]);
|
||||
expect(screen.getByText("quotaExceeded")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a link to the retired audit page opens the admin tab, and an action opens its request's lines", async () => {
|
||||
window.history.replaceState(null, "", "/?page=audit");
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy());
|
||||
expect(url().get("page")).toBe("activity");
|
||||
expect(runsMock).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(rowButton(/auditActions\.scheduler_interval/));
|
||||
fireEvent.click(screen.getByRole("button", { name: "activity.audit.requestLines" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ request_id: "0efdd960c9ad1977" }),
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect(url().get("lreq")).toBe("0efdd960c9ad1977");
|
||||
});
|
||||
|
||||
it("the account filter reaches the quota tab, over the page's range", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity&at=quota&au=none&aw=30d");
|
||||
renderPage();
|
||||
await waitFor(() => expect(quotaMock).toHaveBeenCalled());
|
||||
const [since, user] = quotaMock.mock.calls[0]!;
|
||||
expect(user).toBe("none");
|
||||
const days = (Date.now() - new Date(since).getTime()) / 86_400_000;
|
||||
expect(Math.round(days)).toBe(30);
|
||||
});
|
||||
|
||||
it("a failed read is an error with a retry, not an empty list", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity");
|
||||
runsMock.mockRejectedValue(new Error("502"));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||
expect(screen.queryByText("activity.runs.empty")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { api } from "../lib/api";
|
||||
import {
|
||||
ACTIVITY_TABS,
|
||||
ACTIVITY_WINDOWS,
|
||||
activityParamsToState,
|
||||
activityParamUpdates,
|
||||
type ActivityTab,
|
||||
type ActivityUrlState,
|
||||
type ActivityWindow,
|
||||
} from "../lib/activityUrlState";
|
||||
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
|
||||
import { DEFAULT_LOGS_STATE, logsParamUpdates } from "../lib/logsUrlState";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { urlWithParams } from "../lib/urlState";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import ActivityAudit from "./ActivityAudit";
|
||||
import ActivityQuota from "./ActivityQuota";
|
||||
import ActivityRuns from "./ActivityRuns";
|
||||
import AdminLogs, { type LogPivot } from "./AdminLogs";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import { InnerInput } from "./ui/Input";
|
||||
import { fieldClass } from "./ui/fieldStyles";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { useTabPanel } from "./ui/tabPanel";
|
||||
|
||||
// Activity ("Napló"): what happened, in one place — the background runs, the admins' actions, the
|
||||
// server's log and the quota spend, each its own table on the server, read here as four tabs under
|
||||
// one filter bar (time range, account, text). The view lives in the URL, so it is a link; a pivot to
|
||||
// one run's or one request's log lines is a new history entry, and Back returns to where it was made.
|
||||
|
||||
// A pivot shows everything the run or request logged: every other filter would only hide part of it,
|
||||
// and the longest range is the log's own retention.
|
||||
const PIVOT_SHARED: Pick<ActivityUrlState, "window" | "user" | "q"> = {
|
||||
window: "90d",
|
||||
user: null,
|
||||
q: "",
|
||||
};
|
||||
|
||||
export default function Activity() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<ActivityUrlState>(() =>
|
||||
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.
|
||||
const [jumps, setJumps] = 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.
|
||||
useUrlMirror({ page: "activity", ...activityParamUpdates(state) });
|
||||
useEffect(() => {
|
||||
const onPop = () => {
|
||||
setJumps((n) => n + 1);
|
||||
setState(activityParamsToState(new URLSearchParams(window.location.search)));
|
||||
};
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
const update = (patch: Partial<ActivityUrlState>) => setState((s) => ({ ...s, ...patch }));
|
||||
|
||||
const pivot = (target: LogPivot) => {
|
||||
const next: ActivityUrlState = { ...state, ...PIVOT_SHARED, tab: "logs" };
|
||||
window.history.pushState(
|
||||
{ ...rememberNavPosition(), sfScroll: nextNavId() },
|
||||
"",
|
||||
urlWithParams({
|
||||
page: "activity",
|
||||
...activityParamUpdates(next),
|
||||
...logsParamUpdates({ ...DEFAULT_LOGS_STATE, ...target }),
|
||||
}),
|
||||
);
|
||||
notifyNav();
|
||||
setJumps((n) => n + 1);
|
||||
setState(next);
|
||||
};
|
||||
|
||||
const shared = { window: state.window, user: state.user, q };
|
||||
return (
|
||||
<div {...panel.panelProps(state.tab)} className="px-4 pb-4 pt-3 max-w-6xl w-full mx-auto">
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 max-w-6xl w-full mx-auto">
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
active={state.tab}
|
||||
onChange={(tab) => update({ tab: tab as ActivityTab })}
|
||||
tabs={ACTIVITY_TABS.map((id) => ({ id, label: t(`activity.tabs.${id}`) }))}
|
||||
className="flex flex-wrap gap-1 pb-2"
|
||||
/>
|
||||
<SharedBar state={state} onChange={update} />
|
||||
</div>
|
||||
</PageToolbar>
|
||||
{/* The quota tab says what its numbers are itself, beside them. */}
|
||||
{state.tab !== "quota" && (
|
||||
<p className="mb-3 text-xs leading-relaxed text-muted">
|
||||
{t(`activity.intro.${state.tab}`)}
|
||||
</p>
|
||||
)}
|
||||
{state.tab === "runs" ? (
|
||||
<ActivityRuns
|
||||
shared={shared}
|
||||
job={state.job}
|
||||
status={state.status}
|
||||
onFilter={update}
|
||||
onLogs={(runId) => pivot({ requestId: null, jobId: null, runId })}
|
||||
/>
|
||||
) : state.tab === "admin" ? (
|
||||
<ActivityAudit
|
||||
shared={shared}
|
||||
action={state.action}
|
||||
onAction={(action) => update({ action })}
|
||||
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} />
|
||||
) : (
|
||||
<ActivityQuota shared={shared} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The filters every tab shares: the text (not on the quota tab — it has nothing to match), the time
|
||||
* range, and the account. */
|
||||
function SharedBar({
|
||||
state,
|
||||
onChange,
|
||||
}: {
|
||||
state: ActivityUrlState;
|
||||
onChange: (patch: Partial<ActivityUrlState>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
|
||||
const known = users.data ?? [];
|
||||
// The account in use stays offered even when the list does not have it (an erased account).
|
||||
const kept =
|
||||
state.user && state.user !== "none" && !known.some((u) => String(u.id) === state.user)
|
||||
? [{ value: state.user, label: `#${state.user}` }]
|
||||
: [];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{state.tab !== "quota" && (
|
||||
<label
|
||||
className={clsx(
|
||||
fieldClass({ size: "sm", pill: true }),
|
||||
"flex min-w-[12rem] flex-1 items-center gap-1.5",
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
|
||||
<InnerInput
|
||||
type="search"
|
||||
value={state.q}
|
||||
onChange={(e) => onChange({ q: e.target.value.slice(0, 200) })}
|
||||
placeholder={t("activity.search")}
|
||||
aria-label={t("activity.search")}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("activity.windowLabel")}
|
||||
value={state.window}
|
||||
onChange={(window) => onChange({ window: window as ActivityWindow })}
|
||||
options={ACTIVITY_WINDOWS.map((w) => ({ value: w, label: t(`activity.windows.${w}`) }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("activity.account")}
|
||||
options={[
|
||||
{ value: "", label: t("activity.any") },
|
||||
{ value: "none", label: t("activity.noAccount") },
|
||||
...known.map((u) => ({ value: String(u.id), label: u.email })),
|
||||
...kept,
|
||||
]}
|
||||
selected={[state.user ?? ""]}
|
||||
onChange={([user]) => onChange({ user: user || null })}
|
||||
testId="activity-user"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
import { api, type AuditQuery, type AuditRow } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { sinceOf } from "../lib/activityUrlState";
|
||||
import { auditActionLabel, auditText } from "../lib/auditText";
|
||||
import { formatDateTime, formatDateTimeSec } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useHistoryList } from "../lib/useHistoryList";
|
||||
import type { Shared } from "./ActivityRuns";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import HistoryList from "./HistoryList";
|
||||
import HistoryRow from "./HistoryRow";
|
||||
import { Button } from "./ui/Button";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
|
||||
// Activity → Admin actions: the append-only who/what/when trail, newest first, filtered on the
|
||||
// server. A row opens to its before/after and to the lines its request logged. Clear stays here,
|
||||
// leaving one row that says who cleared it.
|
||||
|
||||
const GRID =
|
||||
"grid-cols-[minmax(0,1fr)_auto] sm:grid-cols-[minmax(0,12rem)_minmax(0,12rem)_8rem_minmax(0,1fr)]";
|
||||
|
||||
export default function ActivityAudit({
|
||||
shared,
|
||||
action,
|
||||
onAction,
|
||||
onRequest,
|
||||
}: {
|
||||
shared: Shared;
|
||||
action: string | null;
|
||||
onAction: (action: string | null) => void;
|
||||
/** Open the server log at this request's lines. */
|
||||
onRequest: (requestId: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const query: AuditQuery = {
|
||||
user: shared.user ?? undefined,
|
||||
q: shared.q.trim() || undefined,
|
||||
action: action ?? undefined,
|
||||
};
|
||||
const key = { ...query, window: shared.window };
|
||||
const state = useHistoryList(qk.adminAudit(key), (before) =>
|
||||
api.adminAudit({ ...query, since: sinceOf(shared.window) }, before),
|
||||
);
|
||||
const facets = useQuery({
|
||||
queryKey: qk.adminAuditFacets(key),
|
||||
queryFn: () => api.adminAuditFacets({ ...query, since: sinceOf(shared.window) }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const actions = [
|
||||
...(facets.data?.actions ?? []),
|
||||
...(action && !facets.data?.actions.some((f) => f.value === action)
|
||||
? [{ value: action, count: 0 }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: () => api.clearAudit(),
|
||||
onSuccess: (r) => {
|
||||
void qc.invalidateQueries({ queryKey: ["admin-audit"] });
|
||||
void qc.invalidateQueries({ queryKey: ["admin-audit-facets"] });
|
||||
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
||||
},
|
||||
onError: (e) => notifyActionError(e, t("audit.clearFailed")),
|
||||
});
|
||||
const onClear = async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: t("audit.clearTitle"),
|
||||
message: t("audit.clearConfirm"),
|
||||
confirmLabel: t("audit.clear"),
|
||||
danger: true,
|
||||
})
|
||||
)
|
||||
clear.mutate();
|
||||
};
|
||||
const actor = (row: AuditRow) =>
|
||||
row.actor_id === null ? null : (state.accounts[String(row.actor_id)] ?? `#${row.actor_id}`);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterSelect
|
||||
label={t("activity.audit.action")}
|
||||
options={[
|
||||
{ value: "", label: t("activity.any") },
|
||||
...actions.map((f) => ({
|
||||
value: f.value,
|
||||
label: auditActionLabel(t, f.value),
|
||||
count: f.count,
|
||||
})),
|
||||
]}
|
||||
selected={[action ?? ""]}
|
||||
onChange={([value]) => onAction(value || null)}
|
||||
testId="audit-action"
|
||||
/>
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
className="ml-auto"
|
||||
onClick={() => void onClear()}
|
||||
disabled={clear.isPending}
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
>
|
||||
{t("audit.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
<HistoryList
|
||||
state={state}
|
||||
empty={t("audit.empty")}
|
||||
count={t("activity.audit.count", { count: state.rows.length })}
|
||||
>
|
||||
{state.rows.map((row, index) => {
|
||||
const { head, sub } = auditText(t, row, lang);
|
||||
const who = actor(row);
|
||||
return (
|
||||
<HistoryRow
|
||||
key={row.id}
|
||||
navIndex={index}
|
||||
navText={`${auditActionLabel(t, row.action)} ${head ?? ""}`}
|
||||
grid={GRID}
|
||||
open={open === row.id}
|
||||
onToggle={() => setOpen(open === row.id ? null : row.id)}
|
||||
title={formatDateTimeSec(row.created_at, lang)}
|
||||
cells={
|
||||
<>
|
||||
<span className="truncate font-medium">{auditActionLabel(t, row.action)}</span>
|
||||
<span className="tabular-nums text-muted sm:hidden">
|
||||
{formatDateTime(row.created_at, lang)}
|
||||
</span>
|
||||
<span
|
||||
className={`hidden truncate sm:block ${who ? "text-muted" : "italic text-muted"}`}
|
||||
>
|
||||
{who ?? t("audit.system")}
|
||||
</span>
|
||||
<span className="hidden tabular-nums text-muted sm:block">
|
||||
{formatDateTime(row.created_at, lang)}
|
||||
</span>
|
||||
<span className="col-span-2 truncate text-muted sm:col-span-1">
|
||||
{[head, sub].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AuditDetail row={row} who={who} onRequest={onRequest} />
|
||||
</HistoryRow>
|
||||
);
|
||||
})}
|
||||
</HistoryList>
|
||||
{facets.isError && <p className="text-2xs text-danger">{t("activity.countsFailed")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditDetail({
|
||||
row,
|
||||
who,
|
||||
onRequest,
|
||||
}: {
|
||||
row: AuditRow;
|
||||
who: string | null;
|
||||
onRequest: (requestId: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { head, sub } = auditText(t, row, lang);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5">
|
||||
<dt>{t("audit.cols.actor")}</dt>
|
||||
<dd>{who ?? t("audit.system")}</dd>
|
||||
<dt>{t("audit.cols.when")}</dt>
|
||||
<dd className="tabular-nums">{formatDateTimeSec(row.created_at, lang)}</dd>
|
||||
{(head || sub) && (
|
||||
<>
|
||||
<dt>{t("audit.cols.details")}</dt>
|
||||
<dd className="wrap-break-word">{[head, sub].filter(Boolean).join(" · ")}</dd>
|
||||
</>
|
||||
)}
|
||||
{row.target_id && (
|
||||
<>
|
||||
<dt>{t("activity.audit.target")}</dt>
|
||||
<dd className="font-mono">{row.target_id}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{(row.before || row.after) && (
|
||||
<pre className="overflow-x-auto whitespace-pre font-mono">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api, type AdminQuotaRow } from "../lib/api";
|
||||
import { sinceOf } from "../lib/activityUrlState";
|
||||
import { formatNumber, quotaActionLabel } from "../lib/format";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import type { Shared } from "./ActivityRuns";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Activity → Quota: the shared YouTube API budget's spend over the page's time range — per day
|
||||
// (Pacific days, as the budget resets), then per account, each opening to its actions. The
|
||||
// account filter narrows both to one account; the text filter has nothing to match here.
|
||||
|
||||
export default function ActivityQuota({ shared }: { shared: Shared }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const q = useQuery({
|
||||
queryKey: qk.adminQuota({ window: shared.window, user: shared.user }),
|
||||
queryFn: () => api.adminQuota(sinceOf(shared.window), shared.user),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const data = q.data;
|
||||
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
|
||||
const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0);
|
||||
|
||||
return (
|
||||
<div className={q.isPlaceholderData ? "opacity-60" : ""} aria-busy={q.isPlaceholderData}>
|
||||
<p className="text-xs text-muted mb-3 leading-relaxed">
|
||||
{t("stats.introBefore")}
|
||||
<b className="text-fg/80">{t("stats.introSystem")}</b>
|
||||
{t("stats.introAfter")}
|
||||
</p>
|
||||
{q.isError && !data ? (
|
||||
<StateMessage tone="error" onRetry={() => void q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !data ? (
|
||||
<LoadingState label={t("stats.loading")} />
|
||||
) : (
|
||||
<>
|
||||
{q.isError && (
|
||||
<StateMessage tone="error" compact onRetry={() => void q.refetch()} className="mb-3">
|
||||
{t("activity.stale")}
|
||||
</StateMessage>
|
||||
)}
|
||||
<div className={`${SURFACE.card} p-3 mb-4`}>
|
||||
<div className="text-xs text-muted mb-2">
|
||||
{t("activity.quota.total", {
|
||||
window: t(`activity.windows.${shared.window}`),
|
||||
units: formatNumber(grandTotal, lang),
|
||||
})}
|
||||
</div>
|
||||
{data.daily.length === 0 ? (
|
||||
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
|
||||
) : (
|
||||
<div className="flex items-end gap-0.5 h-24">
|
||||
{data.daily.map((d) => (
|
||||
<div
|
||||
key={d.day}
|
||||
// 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", {
|
||||
day: d.day,
|
||||
units: formatNumber(d.total, lang),
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-accent rounded-t"
|
||||
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{data.rows.length === 0 ? (
|
||||
<div className="text-muted py-4">{t("stats.noUsageInRange")}</div>
|
||||
) : (
|
||||
data.rows.map((r) => (
|
||||
<UserRow key={r.user_id ?? "system"} row={r} max={data.rows[0]?.total || 1} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const [open, setOpen] = useState(false);
|
||||
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
|
||||
const isSystem = row.user_id === null;
|
||||
return (
|
||||
<div className={`${SURFACE.card} p-3`}>
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-3"
|
||||
>
|
||||
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
|
||||
{isSystem ? t("stats.system") : row.email}
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums shrink-0">
|
||||
{formatNumber(row.total, lang)}
|
||||
</span>
|
||||
</Pressable>
|
||||
{/* Proportion bar */}
|
||||
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full"
|
||||
style={{ width: `${Math.max(2, (row.total / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{open && actions.length > 0 && (
|
||||
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||
{actions.map(([action, units]) => (
|
||||
<div key={action} className="flex items-center justify-between gap-2">
|
||||
<span>{quotaActionLabel(t, action)}</span>
|
||||
<span className="tabular-nums">{formatNumber(units, lang)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api, type LogFacet, type RunQuery, type RunRow } from "../lib/api";
|
||||
import {
|
||||
RUN_STATUSES,
|
||||
sinceOf,
|
||||
type ActivityUrlState,
|
||||
type RunStatusFilter,
|
||||
} from "../lib/activityUrlState";
|
||||
import { formatDateTime, formatDateTimeSec, formatNumber, formatRuntime } from "../lib/format";
|
||||
import { jobFailureText, jobResultText } from "../lib/jobResult";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useHistoryList } from "../lib/useHistoryList";
|
||||
import HistoryList from "./HistoryList";
|
||||
import HistoryRow from "./HistoryRow";
|
||||
import { StatusDot } from "./RunStatusDot";
|
||||
import { Button } from "./ui/Button";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
|
||||
// Activity → Runs: every background run, newest first — the scheduled ones, an admin's "run now", a
|
||||
// user's work on one channel. A row opens to who started it, when, what it returned or why it
|
||||
// failed, and two ways on: the lines the run logged, and the other runs of its job.
|
||||
|
||||
const GRID =
|
||||
"grid-cols-[0.75rem_minmax(0,1fr)_auto] sm:grid-cols-[0.75rem_minmax(0,12rem)_8rem_8rem_4rem_3.5rem_minmax(0,1fr)]";
|
||||
|
||||
/** The shared filters, as the page passes them down. */
|
||||
export interface Shared {
|
||||
window: ActivityUrlState["window"];
|
||||
user: string | null;
|
||||
q: string;
|
||||
}
|
||||
|
||||
export default function ActivityRuns({
|
||||
shared,
|
||||
job,
|
||||
status,
|
||||
onFilter,
|
||||
onLogs,
|
||||
}: {
|
||||
shared: Shared;
|
||||
job: string | null;
|
||||
status: RunStatusFilter | null;
|
||||
onFilter: (patch: { job?: string | null; status?: RunStatusFilter | null }) => void;
|
||||
/** Open the server log at this run's lines. */
|
||||
onLogs: (runId: number) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const query: RunQuery = {
|
||||
user: shared.user ?? undefined,
|
||||
q: shared.q.trim() || undefined,
|
||||
job: job ?? undefined,
|
||||
status: status ?? undefined,
|
||||
};
|
||||
const key = { ...query, window: shared.window };
|
||||
const state = useHistoryList(qk.adminRuns(key), (before) =>
|
||||
api.adminRuns({ ...query, since: sinceOf(shared.window) }, before),
|
||||
);
|
||||
const facets = useQuery({
|
||||
queryKey: qk.adminRunFacets(key),
|
||||
queryFn: () => api.adminRunFacets({ ...query, since: sinceOf(shared.window) }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const count = (list: LogFacet[] | undefined, value: string) =>
|
||||
list?.find((f) => f.value === value)?.count ?? (facets.data ? 0 : undefined);
|
||||
const jobName = (id: string) => t(`scheduler.jobs.${id}`, id);
|
||||
// The job in use stays offered at zero: under the other filters it may count nothing, and a menu
|
||||
// without it would show no way back.
|
||||
const jobs = [
|
||||
...(facets.data?.jobs ?? []),
|
||||
...(job && !facets.data?.jobs.some((f) => f.value === job) ? [{ value: job, count: 0 }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterSelect
|
||||
label={t("activity.runs.job")}
|
||||
options={[
|
||||
{ value: "", label: t("activity.any") },
|
||||
...jobs.map((f) => ({ value: f.value, label: jobName(f.value), count: f.count })),
|
||||
]}
|
||||
selected={[job ?? ""]}
|
||||
onChange={([value]) => onFilter({ job: value || null })}
|
||||
testId="runs-job"
|
||||
/>
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("activity.runs.statusLabel")}
|
||||
value={status ?? "all"}
|
||||
onChange={(value) =>
|
||||
onFilter({ status: value === "all" ? null : (value as RunStatusFilter) })
|
||||
}
|
||||
options={[
|
||||
{ value: "all", label: t("activity.any"), count: facets.data?.total },
|
||||
...RUN_STATUSES.map((s) => ({
|
||||
value: s,
|
||||
label: t(`activity.runs.status.${s}`),
|
||||
count: count(facets.data?.statuses, s),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<HistoryList
|
||||
state={state}
|
||||
empty={t("activity.runs.empty")}
|
||||
count={t("activity.runs.count", { count: state.rows.length })}
|
||||
>
|
||||
{state.rows.map((run, index) => (
|
||||
<HistoryRow
|
||||
key={run.id}
|
||||
navIndex={index}
|
||||
navText={`${jobName(run.job)} ${run.target_title ?? ""}`}
|
||||
grid={GRID}
|
||||
open={open === run.id}
|
||||
onToggle={() => setOpen(open === run.id ? null : run.id)}
|
||||
title={formatDateTimeSec(run.started_at, lang)}
|
||||
cells={
|
||||
<RunCells
|
||||
run={run}
|
||||
actor={run.actor_id === null ? null : state.accounts[String(run.actor_id)]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<RunDetail
|
||||
run={run}
|
||||
actor={run.actor_id === null ? null : state.accounts[String(run.actor_id)]}
|
||||
onLogs={() => onLogs(run.id)}
|
||||
onJob={() => onFilter({ job: run.job })}
|
||||
/>
|
||||
</HistoryRow>
|
||||
))}
|
||||
</HistoryList>
|
||||
{facets.isError && <p className="text-2xs text-danger">{t("activity.countsFailed")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function seconds(run: RunRow): number | null {
|
||||
if (!run.finished_at) return null;
|
||||
return (new Date(run.finished_at).getTime() - new Date(run.started_at).getTime()) / 1000;
|
||||
}
|
||||
|
||||
/** What the run came to, in the reader's language: its failure, that the process stopped under it,
|
||||
* or its result. */
|
||||
function useOutcome(run: RunRow): { text: string; tone: string } {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
if (run.status === "error")
|
||||
return {
|
||||
text: jobFailureText(t, run.failure, lang) ?? run.error ?? t("scheduler.dot.error"),
|
||||
tone: "text-danger",
|
||||
};
|
||||
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" };
|
||||
return { text: run.summary ?? "", tone: "text-muted" };
|
||||
}
|
||||
|
||||
function RunCells({ run, actor }: { run: RunRow; actor: string | null | undefined }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const outcome = useOutcome(run);
|
||||
const secs = seconds(run);
|
||||
const name = t(`scheduler.jobs.${run.job}`, run.job);
|
||||
return (
|
||||
<>
|
||||
<StatusDot k={run.status} label={t(`scheduler.dot.${run.status}`)} />
|
||||
<span className="truncate font-medium">
|
||||
{name}
|
||||
{run.target_title && <span className="font-normal text-muted"> · {run.target_title}</span>}
|
||||
</span>
|
||||
{/* A phone has room for the name and the time only; the row opens to the rest. */}
|
||||
<span className="tabular-nums text-muted sm:hidden">
|
||||
{formatDateTime(run.started_at, lang)}
|
||||
</span>
|
||||
<span className="hidden truncate text-muted sm:block">
|
||||
{t(`scheduler.trigger.${run.trigger}`)}
|
||||
{actor && ` · ${actor}`}
|
||||
</span>
|
||||
<span className="hidden tabular-nums text-muted sm:block">
|
||||
{formatDateTime(run.started_at, lang)}
|
||||
</span>
|
||||
<span className="hidden tabular-nums text-muted sm:block">
|
||||
{secs === null ? "—" : formatRuntime(Math.max(1, secs), lang)}
|
||||
</span>
|
||||
<span className="hidden tabular-nums text-muted sm:block">
|
||||
{formatNumber(run.quota_units, lang)}
|
||||
</span>
|
||||
<span className={`col-span-3 truncate sm:col-span-1 ${outcome.tone}`}>{outcome.text}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RunDetail({
|
||||
run,
|
||||
actor,
|
||||
onLogs,
|
||||
onJob,
|
||||
}: {
|
||||
run: RunRow;
|
||||
actor: string | null | undefined;
|
||||
onLogs: () => void;
|
||||
onJob: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const link = "underline decoration-dotted underline-offset-2 hover:decoration-solid";
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5">
|
||||
<dt>{t("activity.runs.fields.started")}</dt>
|
||||
<dd>
|
||||
{run.actor_id === null
|
||||
? t(`scheduler.trigger.${run.trigger}`)
|
||||
: `${actor ?? `#${run.actor_id}`} · ${t(`scheduler.trigger.${run.trigger}`)}`}
|
||||
</dd>
|
||||
<dt>{t("activity.runs.fields.when")}</dt>
|
||||
<dd className="tabular-nums">
|
||||
{formatDateTimeSec(run.started_at, lang)}
|
||||
{run.finished_at && ` → ${formatDateTimeSec(run.finished_at, lang)}`}
|
||||
</dd>
|
||||
{run.target && (
|
||||
<>
|
||||
<dt>{t("activity.runs.fields.target")}</dt>
|
||||
<dd>
|
||||
{run.target_title ? `${run.target_title} · ` : ""}
|
||||
<span className="font-mono">{run.target}</span>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t("activity.runs.fields.quota")}</dt>
|
||||
<dd className="tabular-nums">{formatNumber(run.quota_units, lang)}</dd>
|
||||
</dl>
|
||||
{run.error && (
|
||||
<pre className="whitespace-pre-wrap break-words text-danger/90">{run.error}</pre>
|
||||
)}
|
||||
{run.result && (
|
||||
<pre className="overflow-x-auto whitespace-pre font-mono">
|
||||
{JSON.stringify(run.result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<Button variant="link" className={link} onClick={onLogs}>
|
||||
{t("activity.runs.logLines", { count: run.log_lines })}
|
||||
</Button>
|
||||
<Button variant="link" className={link} onClick={onJob}>
|
||||
{t("activity.runs.jobRuns")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The admin log console. A request id narrows the list to that request (and says so, in the
|
||||
// filter row and the URL); a filter value the facets no longer count stays pickable; "Live" watches
|
||||
// the newest page only, and stops on a failed read instead of re-raising it; a failed read is an
|
||||
// error, not an empty log; Copy takes every loaded line, oldest first.
|
||||
// The activity page's server-log tab, rendered through the page (which owns the shared filters and
|
||||
// the pivots). A request or run id narrows the list to that request or run (and says so, in the
|
||||
// filter row and the URL), and Back returns to the view the pivot was made from; an old link to the
|
||||
// retired log page opens this tab; the account in use stays pickable; "Live" watches the newest page
|
||||
// only, and stops on a failed read instead of re-raising it; a failed read is an error, not an empty
|
||||
// log; Copy takes every loaded line, oldest first.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -32,6 +34,7 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
...actual.api,
|
||||
adminLogs: (query: LogQuery, before: string | null) => logsMock(query, before),
|
||||
adminLogFacets: (query: LogQuery) => facetsMock(query),
|
||||
adminUsers: () => Promise.resolve([{ id: 1, email: "admin@example.com" }]),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -44,7 +47,11 @@ vi.mock("../lib/clipboard", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import AdminLogs from "./AdminLogs";
|
||||
import Activity from "./Activity";
|
||||
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
|
||||
|
||||
// The filter bar lives in the toolbar, which portals into the shell's band: give it one.
|
||||
const slot = document.body.appendChild(document.createElement("div"));
|
||||
|
||||
function row(id: number, extra: Partial<ServerLogRow> = {}): ServerLogRow {
|
||||
return {
|
||||
@@ -60,6 +67,7 @@ function row(id: number, extra: Partial<ServerLogRow> = {}): ServerLogRow {
|
||||
request_id: `req-${String(id).padStart(8, "0")}`,
|
||||
user_id: 1,
|
||||
job_id: null,
|
||||
run_id: null,
|
||||
debug_session_id: null,
|
||||
version: "0.90.0",
|
||||
...extra,
|
||||
@@ -79,7 +87,9 @@ function renderConsole() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AdminLogs />
|
||||
<PageToolbarSlot.Provider value={slot}>
|
||||
<Activity />
|
||||
</PageToolbarSlot.Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
@@ -93,7 +103,7 @@ const lines = () =>
|
||||
.filter((b) => b.hasAttribute("aria-expanded"));
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "", "/?page=logs");
|
||||
window.history.replaceState(null, "", "/?page=activity&at=logs");
|
||||
facetsMock.mockResolvedValue(FACETS);
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -105,7 +115,7 @@ afterEach(() => {
|
||||
|
||||
describe("AdminLogs", () => {
|
||||
it("a request id narrows the list to that request, with every other filter off", async () => {
|
||||
window.history.replaceState(null, "", "/?page=logs&lgrp=error&lq=boom");
|
||||
window.history.replaceState(null, "", "/?page=activity&at=logs&lgrp=error&aq=boom");
|
||||
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
|
||||
renderConsole();
|
||||
|
||||
@@ -121,11 +131,15 @@ describe("AdminLogs", () => {
|
||||
);
|
||||
expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy();
|
||||
const url = new URLSearchParams(window.location.search);
|
||||
expect([url.get("lreq"), url.get("lgrp"), url.get("lq")]).toEqual(["req-00000001", null, null]);
|
||||
expect([url.get("lreq"), url.get("lgrp"), url.get("aq")]).toEqual(["req-00000001", null, null]);
|
||||
});
|
||||
|
||||
it("Back from a request's lines returns to the view the pivot was made from", async () => {
|
||||
window.history.replaceState({ sfPage: "logs" }, "", "/?page=logs&lgrp=error&lq=boom");
|
||||
window.history.replaceState(
|
||||
{ sfPage: "activity" },
|
||||
"",
|
||||
"/?page=activity&at=logs&lgrp=error&aq=boom",
|
||||
);
|
||||
const depth = window.history.length;
|
||||
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
|
||||
renderConsole();
|
||||
@@ -157,23 +171,60 @@ describe("AdminLogs", () => {
|
||||
);
|
||||
expect(screen.queryByText("adminLogs.pivotRequest:req-00000001")).toBeNull();
|
||||
const url = new URLSearchParams(window.location.search);
|
||||
expect([url.get("lreq"), url.get("lgrp"), url.get("lq")]).toEqual([null, "error", "boom"]);
|
||||
expect([url.get("lreq"), url.get("lgrp"), url.get("aq")]).toEqual([null, "error", "boom"]);
|
||||
// Neither jump waited out the typing debounce: no read ever mixed one view's filters with the
|
||||
// other's search text (the request with "boom", or the restored view without it).
|
||||
const mixed = logsMock.mock.calls.filter(([q]) => (q.request_id != null) === (q.q === "boom"));
|
||||
expect(mixed).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the account in use offered when the facets no longer count it", async () => {
|
||||
window.history.replaceState(null, "", "/?page=logs&lu=7");
|
||||
it("a run id narrows the list to that run's lines", async () => {
|
||||
logsMock.mockResolvedValue({
|
||||
items: [row(1, { run_id: 77 })],
|
||||
accounts: {},
|
||||
next_cursor: null,
|
||||
});
|
||||
renderConsole();
|
||||
await waitFor(() => expect(lines()).toHaveLength(1));
|
||||
fireEvent.click(lines()[0]!);
|
||||
fireEvent.click(screen.getByRole("button", { name: "77" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ run_id: 77, request_id: undefined }),
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect(screen.getByText("adminLogs.pivotRun:77")).toBeTruthy();
|
||||
expect(new URLSearchParams(window.location.search).get("lrun")).toBe("77");
|
||||
});
|
||||
|
||||
it("an old link to the server log page opens this tab with its filter", async () => {
|
||||
window.history.replaceState(null, "", "/?page=logs&lreq=req-00000009");
|
||||
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
|
||||
renderConsole();
|
||||
await waitFor(() => expect(facetsMock).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByTestId("logs-user"));
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ request_id: "req-00000009" }),
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect(new URLSearchParams(window.location.search).get("page")).toBe("activity");
|
||||
});
|
||||
|
||||
expect(await screen.findByTestId("logs-user-7")).toBeTruthy();
|
||||
expect(screen.getByTestId("logs-user-1")).toBeTruthy();
|
||||
it("keeps the account in use offered when the account list does not have it", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity&at=logs&au=7");
|
||||
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
|
||||
renderConsole();
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenCalledWith(expect.objectContaining({ user: "7" }), null),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("activity-user"));
|
||||
|
||||
expect(await screen.findByTestId("activity-user-7")).toBeTruthy();
|
||||
expect(screen.getByTestId("activity-user-1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Live watches the newest page only", async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
keepPreviousData,
|
||||
@@ -7,26 +6,17 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { Copy, Radio, Search } from "lucide-react";
|
||||
import { Copy, Radio } from "lucide-react";
|
||||
|
||||
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { formatDateTimeSec } from "../lib/format";
|
||||
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
|
||||
import { nextPageOnClick } from "../lib/nextPage";
|
||||
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
|
||||
import {
|
||||
DEFAULT_LOGS_STATE,
|
||||
logsParamsToState,
|
||||
logsParamUpdates,
|
||||
WINDOW_HOURS,
|
||||
type LogsUrlState,
|
||||
type LogWindow,
|
||||
} from "../lib/logsUrlState";
|
||||
import { sinceOf } from "../lib/activityUrlState";
|
||||
import { logsParamsToState, logsParamUpdates, type LogsUrlState } from "../lib/logsUrlState";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { urlWithParams } from "../lib/urlState";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import LogLine from "./LogLine";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
@@ -35,46 +25,45 @@ import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import { FilterFoldButton } from "./ui/FilterFold";
|
||||
import { useFilterFold } from "../lib/useFilterFold";
|
||||
import { useStickyTop } from "./ui/stickyTop";
|
||||
import { Section } from "./ui/form";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { DismissibleChip } from "./ui/Chip";
|
||||
import { InnerInput } from "./ui/Input";
|
||||
import { fieldClass } from "./ui/fieldStyles";
|
||||
import type { Shared } from "./ActivityRuns";
|
||||
|
||||
// Admin → Szervernapló: the server's stored log (`log_events`). One list, newest first; a line
|
||||
// opens in place to its full record, and its request id / job id narrow the list to that request or
|
||||
// job. Filters live in the URL, so a filtered view is a link. "Élő" re-reads the newest page every
|
||||
// Activity → Server log: the server's stored log (`log_events`). One list, newest first; a line
|
||||
// opens in place to its full record, and its request / job / run id narrows the list to that
|
||||
// request, job or run (the page does that: a pivot is a new history entry with every other filter
|
||||
// off). The time range, account and text come from the page's shared bar; the rest live in the URL
|
||||
// (./logsUrlState), so a filtered view is a link. "Élő" re-reads the newest page every
|
||||
// few seconds while the tab is visible — the newest page only: live is watching the top, so paging
|
||||
// into older rows turns it off. A failed read stops the polling (the request already reported it
|
||||
// once) and marks the list as possibly stale, instead of re-raising the failure every few seconds.
|
||||
|
||||
const LIVE_MS = 4000;
|
||||
const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"];
|
||||
const WINDOWS: readonly LogWindow[] = ["1h", "24h", "7d"];
|
||||
// A pivot shows everything about one request or job: the other filters would only hide part of it.
|
||||
const PIVOT_RESET: Omit<LogsUrlState, "requestId" | "jobId"> = {
|
||||
...DEFAULT_LOGS_STATE,
|
||||
window: "7d",
|
||||
};
|
||||
|
||||
function sinceOf(window: LogWindow): string {
|
||||
return new Date(Date.now() - WINDOW_HOURS[window] * 3_600_000).toISOString();
|
||||
}
|
||||
|
||||
function toQuery(state: LogsUrlState, q: string): LogQuery {
|
||||
function toQuery(state: LogsUrlState, shared: Shared): LogQuery {
|
||||
return {
|
||||
levels: state.group === "all" ? undefined : LEVEL_GROUPS[state.group],
|
||||
sources: state.sources,
|
||||
user: state.user ?? undefined,
|
||||
user: shared.user ?? undefined,
|
||||
event: state.event ?? undefined,
|
||||
request_id: state.requestId ?? undefined,
|
||||
job_id: state.jobId ?? undefined,
|
||||
q: q.trim() || undefined,
|
||||
run_id: state.runId ?? undefined,
|
||||
q: shared.q.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminLogs() {
|
||||
/** The ids a line can narrow the list to. */
|
||||
export type LogPivot = Pick<LogsUrlState, "requestId" | "jobId" | "runId">;
|
||||
|
||||
export default function AdminLogs({
|
||||
shared,
|
||||
onPivot,
|
||||
}: {
|
||||
shared: Shared;
|
||||
/** Show only one request's, job's or run's lines (the page clears every other filter). */
|
||||
onPivot: (pivot: LogPivot) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
@@ -82,14 +71,9 @@ export default function AdminLogs() {
|
||||
logsParamsToState(new URLSearchParams(window.location.search)),
|
||||
);
|
||||
const [live, setLiveState] = useState(false);
|
||||
// The toolbar sticks at the scroller's top (./ui/stickyTop), and folds its facet row when short.
|
||||
useStickyTop();
|
||||
// The facet row folds away when the screen is short.
|
||||
const fold = useFilterFold();
|
||||
const facetFilters =
|
||||
state.sources.length +
|
||||
(state.user ? 1 : 0) +
|
||||
(state.event ? 1 : 0) +
|
||||
(state.window !== DEFAULT_LOGS_STATE.window ? 1 : 0);
|
||||
const facetFilters = state.sources.length + (state.event ? 1 : 0);
|
||||
// Read by `getNextPageParam` when a page is fetched, not when it renders: while live, a refetch
|
||||
// stops after the newest page, however many pages the cache holds — a "Load older" that lands
|
||||
// after Live was switched on, or a filter set revisited with older pages still cached.
|
||||
@@ -99,41 +83,26 @@ export default function AdminLogs() {
|
||||
setLiveState(on);
|
||||
};
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
// Bumped when the view changes by a jump (a pivot, Back/Forward) rather than by typing: the
|
||||
// search box's text then applies at once instead of after the typing debounce.
|
||||
const [jumps, setJumps] = useState(0);
|
||||
const q = useDebounced(state.q, 300, jumps);
|
||||
useUrlMirror(logsParamUpdates(state));
|
||||
// Back/Forward lands on another view of the console (a pivot pushed one, below): read it back.
|
||||
useEffect(() => {
|
||||
const onPop = () => {
|
||||
setOpen(null);
|
||||
setJumps((n) => n + 1);
|
||||
setState(logsParamsToState(new URLSearchParams(window.location.search)));
|
||||
};
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
|
||||
const update = (patch: Partial<LogsUrlState>) => {
|
||||
setOpen(null);
|
||||
setState((s) => ({ ...s, ...patch }));
|
||||
};
|
||||
|
||||
const query = toQuery(state, q);
|
||||
const key = qk.adminLogs({ ...query, window: state.window });
|
||||
const query = toQuery(state, shared);
|
||||
const key = qk.adminLogs({ ...query, window: shared.window });
|
||||
const logs = useInfiniteQuery({
|
||||
queryKey: key,
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.adminLogs({ ...query, since: sinceOf(state.window) }, pageParam),
|
||||
api.adminLogs({ ...query, since: sinceOf(shared.window) }, pageParam),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => (liveRef.current ? undefined : (last.next_cursor ?? undefined)),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS : false),
|
||||
});
|
||||
const facets = useQuery({
|
||||
queryKey: qk.adminLogFacets({ ...query, window: state.window }),
|
||||
queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(state.window) }),
|
||||
queryKey: qk.adminLogFacets({ ...query, window: shared.window }),
|
||||
queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(shared.window) }),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS * 4 : false),
|
||||
});
|
||||
@@ -217,19 +186,9 @@ export default function AdminLogs() {
|
||||
return facets.data ? sum(levels.filter((f) => inGroup.includes(f.value))) : undefined;
|
||||
};
|
||||
|
||||
// A pivot follows a link to another view, so it is its own history entry and Back returns to the
|
||||
// view it was made from. The other filters are adjustments of the view on screen and replace it.
|
||||
const pivot = (patch: Pick<LogsUrlState, "requestId" | "jobId">) => {
|
||||
const pivot = (patch: Partial<LogPivot>) => {
|
||||
setLive(false);
|
||||
const next = { ...state, ...PIVOT_RESET, ...patch };
|
||||
window.history.pushState(
|
||||
{ ...rememberNavPosition(), sfScroll: nextNavId() },
|
||||
"",
|
||||
urlWithParams(logsParamUpdates(next)),
|
||||
);
|
||||
notifyNav();
|
||||
setJumps((n) => n + 1);
|
||||
update(next);
|
||||
onPivot({ requestId: null, jobId: null, runId: null, ...patch });
|
||||
};
|
||||
|
||||
// "Any", then what the facet counted. The value in use stays offered even at zero — under the
|
||||
@@ -253,193 +212,155 @@ export default function AdminLogs() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-6xl w-full mx-auto">
|
||||
<Section card title={t("adminLogs.title")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("adminLogs.intro")}</p>
|
||||
|
||||
<div
|
||||
data-testid="admin-logs"
|
||||
// overflow-clip, not -hidden: a hidden box is a scroll container, and the toolbar would stick
|
||||
// to it instead of to the page.
|
||||
className="overflow-clip rounded-xl border border-border bg-bg"
|
||||
>
|
||||
{/* The console's toolbar is the page's band (B4, B9): it sticks at the top with the fade
|
||||
under it while the lines scroll, so a filter is in reach at line 500. */}
|
||||
<div className="sticky top-0 z-base bg-bg fade-below">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||
<label
|
||||
className={clsx(
|
||||
fieldClass({ size: "sm", pill: true }),
|
||||
"flex min-w-[12rem] flex-1 items-center gap-1.5",
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
|
||||
<InnerInput
|
||||
type="search"
|
||||
value={state.q}
|
||||
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
|
||||
placeholder={t("adminLogs.search")}
|
||||
aria-label={t("adminLogs.search")}
|
||||
/>
|
||||
</label>
|
||||
<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.account")}
|
||||
options={facetOptions(facets.data?.users, state.user, accountLabel)}
|
||||
selected={[state.user ?? ""]}
|
||||
onChange={([user]) => update({ user: user || null })}
|
||||
testId="logs-user"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.window")}
|
||||
options={WINDOWS.map((w) => ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
|
||||
selected={[state.window]}
|
||||
onChange={([window]) => update({ window: window as LogWindow })}
|
||||
testId="logs-window"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<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) && (
|
||||
<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 })}
|
||||
/>
|
||||
)}
|
||||
</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, jobId: null })}
|
||||
onJob={(jobId) => pivot({ requestId: null, jobId })}
|
||||
/>
|
||||
</LogLine>
|
||||
))}
|
||||
</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>{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 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>
|
||||
</Section>
|
||||
|
||||
<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 })}
|
||||
/>
|
||||
)}
|
||||
</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 })}
|
||||
/>
|
||||
</LogLine>
|
||||
))}
|
||||
</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>{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>
|
||||
);
|
||||
}
|
||||
@@ -465,11 +386,13 @@ function RowDetail({
|
||||
account,
|
||||
onRequest,
|
||||
onJob,
|
||||
onRun,
|
||||
}: {
|
||||
row: ServerLogRow;
|
||||
account: string | null;
|
||||
onRequest: (id: string) => void;
|
||||
onJob: (id: number) => void;
|
||||
onRun: (id: number) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -516,6 +439,21 @@ function RowDetail({
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{row.run_id !== null && (
|
||||
<>
|
||||
<dt>{t("adminLogs.fields.run")}</dt>
|
||||
<dd>
|
||||
<Button
|
||||
variant="link"
|
||||
className={pivotCls}
|
||||
title={t("adminLogs.showRun")}
|
||||
onClick={() => onRun(row.run_id!)}
|
||||
>
|
||||
{row.run_id}
|
||||
</Button>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{row.version && (
|
||||
<>
|
||||
<dt>{t("adminLogs.fields.version")}</dt>
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { LS } from "../lib/storage";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Section } from "./ui/form";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import DataTable from "./ui/DataTable";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { auditColumns } from "./auditColumns";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { compareText } from "../lib/collate";
|
||||
|
||||
// Admin-only audit trail: an append-only who/what/when log of admin actions (never per-row; a
|
||||
// background run is not one — runs have their own history). Read-only apart from Clear. Client-side sort/filter/paginate via
|
||||
// DataTable; the API returns the most recent window (capped) — the log stays small by design.
|
||||
export default function AuditLog() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
|
||||
const data = q.data;
|
||||
const rows = useMemo(() => data?.items ?? [], [data]);
|
||||
|
||||
const actionOptions = useMemo(() => {
|
||||
// By what the reader sees — the named action, in their alphabet — not by the code.
|
||||
return Array.from(new Set(rows.map((r) => r.action)))
|
||||
.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) }))
|
||||
.sort((a, b) => compareText(a.label, b.label, lang));
|
||||
}, [rows, t, lang]);
|
||||
const columns = useMemo(() => auditColumns(t, lang, actionOptions), [t, lang, actionOptions]);
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: () => api.clearAudit(),
|
||||
onSuccess: (r) => {
|
||||
qc.invalidateQueries({ queryKey: qk.adminAudit() });
|
||||
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
||||
},
|
||||
onError: (e) => notifyActionError(e, t("audit.clearFailed")),
|
||||
});
|
||||
const onClear = async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: t("audit.clearTitle"),
|
||||
message: t("audit.clearConfirm"),
|
||||
confirmLabel: t("audit.clear"),
|
||||
danger: true,
|
||||
})
|
||||
)
|
||||
clear.mutate();
|
||||
};
|
||||
|
||||
const truncated = !!data && data.total > data.returned;
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-5xl w-full mx-auto">
|
||||
<Section card title={t("audit.title")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("audit.intro")}</p>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState shape="table" />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("audit.empty")}</p>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(r) => String(r.id)}
|
||||
persistKey={LS.auditTable}
|
||||
controlsPosition="top"
|
||||
controlsInBand
|
||||
controlsBandClassName="px-4 max-w-5xl mx-auto"
|
||||
controlsLeading={
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
onClick={onClear}
|
||||
disabled={clear.isPending}
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t("audit.clear")}
|
||||
</Button>
|
||||
}
|
||||
emptyText={t("audit.empty")}
|
||||
/>
|
||||
{truncated && (
|
||||
<p className="text-2xs text-muted mt-2">
|
||||
{t("audit.truncated", { returned: data!.returned, total: data!.total })}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { nextPageOnClick } from "../lib/nextPage";
|
||||
import type { HistoryList as HistoryListState } from "../lib/useHistoryList";
|
||||
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".
|
||||
|
||||
export default function HistoryList<T>({
|
||||
state,
|
||||
empty,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
state: HistoryListState<T>;
|
||||
/** Shown when the filters match nothing. */
|
||||
empty: string;
|
||||
/** "N runs shown", for the footer. */
|
||||
count: string;
|
||||
/** The rows, drawn from `state.rows`. */
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { list, rows, hasOlder } = state;
|
||||
// A read that failed while rows were on screen keeps them, and says what the failure means.
|
||||
const failure = !list.data
|
||||
? null
|
||||
: list.isFetchNextPageError
|
||||
? "olderFailed"
|
||||
: list.isError
|
||||
? "stale"
|
||||
: 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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.
|
||||
|
||||
export default function HistoryRow({
|
||||
cells,
|
||||
grid,
|
||||
navIndex,
|
||||
navText,
|
||||
open,
|
||||
onToggle,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
/** The row's cells, laid out by `grid`. */
|
||||
cells: ReactNode;
|
||||
/** The grid's column template classes (phone first, then wider screens). */
|
||||
grid: string;
|
||||
navIndex: number;
|
||||
/** What the keyboard's type-ahead matches. */
|
||||
navText: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
/** The row button's tooltip — the full date. */
|
||||
title?: string;
|
||||
/** The detail shown while the row is open. */
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="border-b border-border/50 last:border-b-0"
|
||||
{...navItemAttrs(navIndex, navText, { inset: true })}
|
||||
>
|
||||
<Pressable
|
||||
selectable
|
||||
layer={!open}
|
||||
data-nav-open
|
||||
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" : ""
|
||||
}`}
|
||||
>
|
||||
{cells}
|
||||
</Pressable>
|
||||
{open && <div className="bg-accent/5 px-3 py-2 text-xs text-muted">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,7 @@ const row = (over: Partial<ServerLogRow>): ServerLogRow => ({
|
||||
request_id: null,
|
||||
user_id: 1,
|
||||
job_id: null,
|
||||
run_id: null,
|
||||
debug_session_id: SID,
|
||||
version: null,
|
||||
...over,
|
||||
|
||||
@@ -151,11 +151,12 @@ describe("NavigationProvider · setPage's same-page guard", () => {
|
||||
expect(result.current.page).toBe("plex");
|
||||
});
|
||||
|
||||
it("leaves every page's own URL keys behind, the log console's included", () => {
|
||||
it("leaves every page's own URL keys behind, the activity page's included", () => {
|
||||
window.history.replaceState(
|
||||
{ sfPage: "logs" },
|
||||
{ sfPage: "activity" },
|
||||
"",
|
||||
"/?page=logs&lgrp=error&lreq=req-abcdef12&pscope=movie&ctab=hidden&keep=1",
|
||||
"/?page=activity&at=logs&aw=24h&au=none&aq=x&rjob=backfill&rst=error&aact=config_set" +
|
||||
"&lgrp=error&lreq=req-abcdef12&lrun=7&pscope=movie&ctab=hidden&keep=1",
|
||||
);
|
||||
const { result } = render();
|
||||
act(() => result.current.setPage("plex"));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLatest } from "../lib/useLatest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { resolvePage, urlWithParams, type Page } from "../lib/urlState";
|
||||
import { LOGS_KEYS } from "../lib/logsUrlState";
|
||||
import { ACTIVITY_KEYS } from "../lib/activityUrlState";
|
||||
import { pageTitleKey } from "../lib/pageMeta";
|
||||
import { useDocumentObjectTitle } from "../lib/documentTitle";
|
||||
import { setAccountRaw } from "../lib/storage";
|
||||
@@ -92,7 +92,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
ctab: null,
|
||||
csort: null,
|
||||
cf: null,
|
||||
...Object.fromEntries(LOGS_KEYS.map((k) => [k, null])),
|
||||
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
|
||||
}),
|
||||
);
|
||||
notifyNav();
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// A background run's status as a dot — the Scheduler's rows and the activity page's run list draw
|
||||
// the same one, so a state reads the same on both.
|
||||
|
||||
// Interrupted is a ring, not a fill: the job did not fail, but its work did not finish either — set
|
||||
// apart from a skipped run (the same colour, filled) and from a failure.
|
||||
const DOT_CLASS = {
|
||||
running: "bg-accent animate-pulse",
|
||||
ok: "bg-success",
|
||||
error: "bg-danger",
|
||||
skipped: "bg-warn",
|
||||
interrupted: "border-2 border-warn",
|
||||
idle: "bg-border",
|
||||
} as const;
|
||||
|
||||
export type DotKey = keyof typeof DOT_CLASS;
|
||||
|
||||
/** A status dot. `label` names it for a screen reader (the tooltip shows only on hover, and only with
|
||||
* hints on); a legend's dot has its words beside it and stays unnamed. */
|
||||
export function StatusDot({ k, label }: { k: DotKey; label?: string }) {
|
||||
return (
|
||||
<span
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
data-dot={k}
|
||||
className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import Scheduler from "./Scheduler";
|
||||
import { NavigationActionsContext } from "./navigationContext";
|
||||
import { api, type RunBrief, type SchedulerAdHoc, type SchedulerStatus } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
@@ -363,3 +364,37 @@ describe("a row's run history", () => {
|
||||
expect(within(row).queryByRole("button", { name: "scheduler.runNow" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a row's history", () => {
|
||||
// The dots show three runs; the rest is the activity page's run list, narrowed to the row's job.
|
||||
it("opens the activity page's runs of that job", async () => {
|
||||
const setPage = vi.fn((_page: string, then?: () => void) => then?.());
|
||||
window.history.replaceState(null, "", "/?page=scheduler");
|
||||
current = status();
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<NavigationActionsContext.Provider
|
||||
value={{
|
||||
setPage,
|
||||
openChannel: () => {},
|
||||
closeChannel: () => {},
|
||||
enterYtSearch: () => {},
|
||||
exitYtSearch: () => {},
|
||||
}}
|
||||
>
|
||||
<QueryClientProvider client={qc}>
|
||||
<Scheduler />
|
||||
</QueryClientProvider>
|
||||
</NavigationActionsContext.Provider>,
|
||||
);
|
||||
await screen.findByText("scheduler.maintenance.batchLabel");
|
||||
const job = current.jobs[0]!.id;
|
||||
const row = screen.getByText(`scheduler.jobs.${job}`).closest<HTMLElement>(".p-3")!;
|
||||
|
||||
fireEvent.click(within(row).getByRole("button", { name: "scheduler.history" }));
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith("activity", expect.any(Function));
|
||||
const url = new URLSearchParams(window.location.search);
|
||||
expect([url.get("page"), url.get("rjob")]).toEqual(["activity", job]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Clock,
|
||||
Database,
|
||||
Gauge,
|
||||
History,
|
||||
Pause,
|
||||
Pencil,
|
||||
Play,
|
||||
@@ -43,6 +44,9 @@ import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { jobFailureText, jobResultText } from "../lib/jobResult";
|
||||
import { StatusDot, type DotKey } from "./RunStatusDot";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
import { urlWithParams } from "../lib/urlState";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
|
||||
@@ -64,33 +68,6 @@ function fmtCountdown(t: TFunction, s: number, lang: string): string {
|
||||
return `${formatUnit(h, "hour", lang)} ${formatUnit(m % 60, "minute", lang)}`;
|
||||
}
|
||||
|
||||
// Interrupted is a ring, not a fill: the job did not fail, but its work did not finish either — set
|
||||
// apart from a skipped run (the same colour, filled) and from a failure.
|
||||
const DOT_CLASS = {
|
||||
running: "bg-accent animate-pulse",
|
||||
ok: "bg-success",
|
||||
error: "bg-danger",
|
||||
skipped: "bg-warn",
|
||||
interrupted: "border-2 border-warn",
|
||||
idle: "bg-border",
|
||||
} as const;
|
||||
|
||||
type DotKey = keyof typeof DOT_CLASS;
|
||||
|
||||
/** A status dot. `label` names it for a screen reader (the tooltip shows only on hover, and only with
|
||||
* hints on); a legend's dot has its words beside it and stays unnamed. */
|
||||
function StatusDot({ k, label }: { k: DotKey; label?: string }) {
|
||||
return (
|
||||
<span
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
data-dot={k}
|
||||
className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusLegend() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -173,6 +150,31 @@ function LastRun({ row, running, target }: { row: RunFields; running: boolean; t
|
||||
);
|
||||
}
|
||||
|
||||
/** A row's way to its whole history: the activity page's run list, narrowed to this job. */
|
||||
function HistoryButton({ job }: { job: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { setPage } = useNavigationActions();
|
||||
return (
|
||||
<Tooltip hint={t("scheduler.history")}>
|
||||
<IconButton
|
||||
label={t("scheduler.history")}
|
||||
noTitle
|
||||
onClick={() =>
|
||||
setPage("activity", () =>
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
urlWithParams({ page: "activity", rjob: job }),
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<History />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A user's run on one target (a channel's full history): its runs as a job's are shown, but it has
|
||||
* no interval and no "run now" — it needs its target, so it is started where that is chosen. */
|
||||
function AdHocRow({ row }: { row: SchedulerAdHoc }) {
|
||||
@@ -201,6 +203,7 @@ function AdHocRow({ row }: { row: SchedulerAdHoc }) {
|
||||
t("scheduler.adHocStarted")
|
||||
)}
|
||||
</div>
|
||||
<HistoryButton job={row.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -382,6 +385,7 @@ function JobRow({
|
||||
<Play />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<HistoryButton job={job.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { History, Pause, Play, RefreshCw } from "lucide-react";
|
||||
import { api, type AdminQuotaRow } from "../lib/api";
|
||||
import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/format";
|
||||
import { History, RefreshCw } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { formatEta, formatNumber, quotaActionLabel } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { useWizard } from "./wizardContext";
|
||||
import { LS, useTabPersistedState } from "../lib/storage";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { useTabPanel } from "./ui/tabPanel";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { Section } from "./ui/form";
|
||||
import { SectionLabel } from "./ui/Headings";
|
||||
import { StatBars, StatTile, StatTiles } from "./ui/StatTiles";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
const RANGES = [7, 30, 90] as const;
|
||||
type StatsTab = "overview" | "system";
|
||||
|
||||
// Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions);
|
||||
// "System" is the admin-only instance-wide quota dashboard + background-sync control. The
|
||||
// per-user content used to live in Settings → Sync; it moved here so Settings stays config-only.
|
||||
// Usage & stats page: your sync status, your own API usage and the manual sync actions (they used to
|
||||
// live in Settings → Sync; they moved here so Settings stays config-only). The instance-wide quota
|
||||
// view is the activity page's Quota tab.
|
||||
export default function Stats() {
|
||||
const me = useMe();
|
||||
const { t } = useTranslation();
|
||||
const isAdmin = me.role === "admin";
|
||||
const [tabRaw, setTab] = useTabPersistedState(LS.statsTab, "overview");
|
||||
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
|
||||
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
|
||||
const panel = useTabPanel();
|
||||
|
||||
return (
|
||||
// A tab panel only where there are tabs: everyone but an admin sees the Overview alone.
|
||||
<div {...(isAdmin ? panel.panelProps(tab) : {})} className="px-4 pb-4 pt-3 max-w-4xl mx-auto">
|
||||
{/* Only admins have a second (System) tab; everyone else just sees the Overview. The tabs are
|
||||
the page's band (B4), so they stay in reach on the long System tab. */}
|
||||
{isAdmin && (
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
tabs={(["overview", "system"] as StatsTab[]).map((id) => ({
|
||||
id,
|
||||
label: t(`stats.tabs.${id}`),
|
||||
}))}
|
||||
className="flex flex-wrap gap-1 pb-2"
|
||||
/>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
)}
|
||||
|
||||
{tab === "system" && isAdmin ? <SystemStats /> : <Overview />}
|
||||
<div className="px-4 pb-4 pt-3 max-w-4xl mx-auto">
|
||||
<Overview />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -230,165 +190,3 @@ function Overview() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle.
|
||||
function SystemStats() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const [days, setDays] = useState<number>(30);
|
||||
const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) });
|
||||
const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
|
||||
const pauseResume = useMutation({
|
||||
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
|
||||
onError: (e, paused) =>
|
||||
notifyActionError(
|
||||
e,
|
||||
t(paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
|
||||
),
|
||||
});
|
||||
const data = q.data;
|
||||
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
|
||||
const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0);
|
||||
const s = status.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
{s && (
|
||||
<Section title={t("stats.sync.admin")}>
|
||||
<Tooltip hint={t("stats.sync.adminPauseHint")}>
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={s.paused ? <Play /> : <Pause />}
|
||||
loading={pauseResume.isPending}
|
||||
onClick={() => pauseResume.mutate(s.paused)}
|
||||
>
|
||||
{s.paused
|
||||
? t("stats.sync.resumeBackgroundSync")
|
||||
: t("stats.sync.pauseBackgroundSync")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<h2 className="text-lg font-semibold">{t("stats.title")}</h2>
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("stats.rangeLabel")}
|
||||
className="flex items-center gap-1.5"
|
||||
options={RANGES.map((d) => ({
|
||||
value: String(d),
|
||||
label: t("stats.rangeDays", { d: formatUnit(d, "day", lang) }),
|
||||
}))}
|
||||
value={String(days)}
|
||||
onChange={(v) => setDays(Number(v))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted mb-4 leading-relaxed">
|
||||
{t("stats.introBefore")}
|
||||
<b className="text-fg/80">{t("stats.introSystem")}</b>
|
||||
{t("stats.introAfter")}
|
||||
</p>
|
||||
|
||||
{q.isError && !data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState label={t("stats.loading")} />
|
||||
) : !data ? (
|
||||
<div className="text-muted py-8">{t("stats.noData")}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Daily totals (instance-wide, Pacific days) */}
|
||||
<div className={`${SURFACE.card} p-3 mb-4`}>
|
||||
<div className="text-xs text-muted mb-2">
|
||||
{t("stats.dailyTotal", {
|
||||
d: formatUnit(data.range_days, "day", lang),
|
||||
units: formatNumber(grandTotal, lang),
|
||||
})}
|
||||
</div>
|
||||
{data.daily.length === 0 ? (
|
||||
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
|
||||
) : (
|
||||
<div className="flex items-end gap-0.5 h-24">
|
||||
{data.daily.map((d) => (
|
||||
<div
|
||||
key={d.day}
|
||||
// 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", {
|
||||
day: d.day,
|
||||
units: formatNumber(d.total, lang),
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-accent rounded-t"
|
||||
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per-user breakdown */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{data.rows.length === 0 ? (
|
||||
<div className="text-muted py-4">{t("stats.noUsageInRange")}</div>
|
||||
) : (
|
||||
data.rows.map((r) => (
|
||||
<UserRow key={r.user_id ?? "system"} row={r} max={data.rows[0]?.total || 1} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const [open, setOpen] = useState(false);
|
||||
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
|
||||
const isSystem = row.user_id === null;
|
||||
return (
|
||||
<div className={`${SURFACE.card} p-3`}>
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-3"
|
||||
>
|
||||
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
|
||||
{isSystem ? t("stats.system") : row.email}
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums shrink-0">
|
||||
{formatNumber(row.total, lang)}
|
||||
</span>
|
||||
</Pressable>
|
||||
{/* Proportion bar */}
|
||||
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full"
|
||||
style={{ width: `${Math.max(2, (row.total / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{open && actions.length > 0 && (
|
||||
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||
{actions.map(([action, units]) => (
|
||||
<div key={action} className="flex items-center justify-between gap-2">
|
||||
<span>{quotaActionLabel(t, action)}</span>
|
||||
<span className="tabular-nums">{formatNumber(units, lang)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,10 +24,7 @@ const CARDS = [
|
||||
const LISTS = ["NavGrid", "Carousel", "VirtualGrid"];
|
||||
|
||||
/** `path#tag#n` → why it is not a keyboard list. An entry that no longer matches a tag fails the test. */
|
||||
const EXEMPT: Record<string, string> = {
|
||||
"components/AuditLog.tsx#DataTable#1":
|
||||
"an admin log read in place: its rows open nothing, so stepping onto them would only move a ring",
|
||||
};
|
||||
const EXEMPT: Record<string, string> = {};
|
||||
|
||||
interface Tag {
|
||||
start: number;
|
||||
|
||||
@@ -32,8 +32,7 @@ const Stats = lazyWithPreload(() => import("./Stats"));
|
||||
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
|
||||
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
|
||||
const AdminUsers = lazyWithPreload(() => import("./AdminUsers"));
|
||||
const AuditLog = lazyWithPreload(() => import("./AuditLog"));
|
||||
const AdminLogs = lazyWithPreload(() => import("./AdminLogs"));
|
||||
const Activity = lazyWithPreload(() => import("./Activity"));
|
||||
const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel"));
|
||||
|
||||
export type PageDef = {
|
||||
@@ -60,8 +59,7 @@ export const PAGE_CONTENT: Record<Page, PageDef> = {
|
||||
scheduler: { Component: Scheduler },
|
||||
config: { Component: ConfigPanel },
|
||||
users: { Component: AdminUsers, maxWidth: "48rem" },
|
||||
audit: { Component: AuditLog },
|
||||
logs: { Component: AdminLogs },
|
||||
activity: { Component: Activity },
|
||||
settings: { Component: SettingsPanel },
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"title": "Activity",
|
||||
"tabs": {
|
||||
"runs": "Runs",
|
||||
"admin": "Admin actions",
|
||||
"logs": "Server log",
|
||||
"quota": "Quota"
|
||||
},
|
||||
"intro": {
|
||||
"runs": "Every background run, newest first — scheduled, started by an admin, or a user's work on one channel. A row opens to what it did or why it failed, and to the lines it logged.",
|
||||
"admin": "An append-only record of admin actions — who changed what, and when. A row opens to its before and after, and to the lines its request logged.",
|
||||
"logs": "The server's stored log — lines from the API, the downloader and browsers, kept until the retention period ends. A line opens in place; its request, job or run id keeps only that one's lines."
|
||||
},
|
||||
"search": "Search…",
|
||||
"windowLabel": "Period",
|
||||
"windows": {
|
||||
"1h": "1 hour",
|
||||
"24h": "24 hours",
|
||||
"7d": "7 days",
|
||||
"30d": "30 days",
|
||||
"90d": "90 days"
|
||||
},
|
||||
"account": "Account",
|
||||
"any": "Any",
|
||||
"noAccount": "No account (background)",
|
||||
"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",
|
||||
"runs": {
|
||||
"job": "Job",
|
||||
"statusLabel": "Filter by status",
|
||||
"status": {
|
||||
"error": "Failed",
|
||||
"interrupted": "Interrupted",
|
||||
"skipped": "Skipped",
|
||||
"running": "Running",
|
||||
"ok": "OK"
|
||||
},
|
||||
"empty": "No runs match the filters.",
|
||||
"count_one": "{{count}} run shown · newest first",
|
||||
"count_other": "{{count}} runs shown · newest first",
|
||||
"fields": {
|
||||
"started": "Started by",
|
||||
"when": "Start → end",
|
||||
"target": "Target",
|
||||
"quota": "Quota units"
|
||||
},
|
||||
"logLines_one": "Its {{count}} log line →",
|
||||
"logLines_other": "Its {{count}} log lines →",
|
||||
"jobRuns": "This job's runs →"
|
||||
},
|
||||
"audit": {
|
||||
"action": "Action",
|
||||
"target": "Target",
|
||||
"requestLines": "Its request's log lines →",
|
||||
"count_one": "{{count}} action shown · newest first",
|
||||
"count_other": "{{count}} actions shown · newest first"
|
||||
},
|
||||
"quota": {
|
||||
"total": "Spend over {{window}}: {{units}} units"
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,7 @@
|
||||
{
|
||||
"title": "Server log",
|
||||
"intro": "The server's stored log — lines from the API, the downloader and browsers, kept until the retention period ends. A line opens in place; click its request or job id to keep only that request's or job's lines.",
|
||||
"search": "Search the message, code, stack…",
|
||||
"levelLabel": "Filter by level",
|
||||
"source": "Source",
|
||||
"account": "Account",
|
||||
"event": "Event",
|
||||
"window": "Period",
|
||||
"windows": {
|
||||
"1h": "1 hour",
|
||||
"24h": "24 hours",
|
||||
"7d": "7 days"
|
||||
},
|
||||
"any": "Any",
|
||||
"noAccount": "No account",
|
||||
"noEvent": "No code",
|
||||
@@ -38,6 +28,9 @@
|
||||
"account": "Account",
|
||||
"request": "Request",
|
||||
"job": "Job",
|
||||
"version": "Version"
|
||||
}
|
||||
"version": "Version",
|
||||
"run": "Run"
|
||||
},
|
||||
"pivotRun": "run {{id}}",
|
||||
"showRun": "Only this run's lines"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"title": "Audit log",
|
||||
"intro": "An append-only record of admin actions — who did what, and when. Logged at action granularity, never per item.",
|
||||
"empty": "No audit entries yet.",
|
||||
"system": "System",
|
||||
"clear": "Clear audit log",
|
||||
@@ -9,7 +7,6 @@
|
||||
"cleared_one": "Cleared {{count}} audit entry",
|
||||
"cleared_other": "Cleared {{count}} audit entries",
|
||||
"clearFailed": "Couldn't clear the audit log",
|
||||
"truncated": "Showing the {{returned}} most recent of {{total}} entries.",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"default": "default",
|
||||
@@ -18,7 +15,6 @@
|
||||
"cols": {
|
||||
"when": "When",
|
||||
"actor": "Who",
|
||||
"action": "Action",
|
||||
"details": "Details"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,8 @@
|
||||
"signOut": "Sign out",
|
||||
"addAccount": "Add another account",
|
||||
"switchTo": "Switch to {{name}}",
|
||||
"audit": "Audit log",
|
||||
"logs": "Server log",
|
||||
"googleAddress": "Google account: {{email}}"
|
||||
"googleAddress": "Google account: {{email}}",
|
||||
"activity": "Activity"
|
||||
},
|
||||
"sync": {
|
||||
"yours": "yours",
|
||||
|
||||
@@ -18,9 +18,8 @@
|
||||
"scheduler": "Scheduler",
|
||||
"config": "Config",
|
||||
"users": "Users",
|
||||
"audit": "Audit",
|
||||
"admin": "Admin",
|
||||
"logs": "Logs",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"activity": "Activity"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,5 +211,6 @@
|
||||
},
|
||||
"interrupted": "interrupted: the server restarted during the run",
|
||||
"adHocTitle": "Ad-hoc runs",
|
||||
"adHocStarted": "started by a user"
|
||||
"adHocStarted": "started by a user",
|
||||
"history": "This job's runs"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
{
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"system": "System"
|
||||
},
|
||||
"title": "API quota usage",
|
||||
"rangeDays": "{{d}}",
|
||||
"rangeLabel": "Time range",
|
||||
"introBefore": "YouTube Data API units attributed by who triggered the spend. ",
|
||||
"introSystem": "System",
|
||||
"introAfter": " is shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.",
|
||||
"loading": "Loading…",
|
||||
"noData": "No data.",
|
||||
"dailyTotal": "Daily total ({{d}} · {{units}} units)",
|
||||
"noUsageInRange": "No usage in this range.",
|
||||
"dailyTooltip": "{{day}}: {{units}} units",
|
||||
"system": "System (background)",
|
||||
@@ -49,10 +40,6 @@
|
||||
"syncFailed": "Couldn't sync subscriptions",
|
||||
"fullHistoryRequested_one": "Full history requested for {{count}} channel",
|
||||
"fullHistoryRequested_other": "Full history requested for {{count}} channels",
|
||||
"fullHistoryFailed": "Couldn't request full history",
|
||||
"admin": "Admin",
|
||||
"adminPauseHint": "Pause or resume the background sync for the whole app (all users).",
|
||||
"resumeBackgroundSync": "Resume background sync",
|
||||
"pauseBackgroundSync": "Pause background sync"
|
||||
"fullHistoryFailed": "Couldn't request full history"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"title": "Napló",
|
||||
"tabs": {
|
||||
"runs": "Futások",
|
||||
"admin": "Admin műveletek",
|
||||
"logs": "Szervernapló",
|
||||
"quota": "Kvóta"
|
||||
},
|
||||
"intro": {
|
||||
"runs": "Minden háttérfutás, a legújabbal kezdve — ütemezett, adminisztrátor indította, vagy egy felhasználó munkája egy csatornán. Egy sor kinyitva megmutatja, mit végzett vagy miért hiúsult meg, és a naplósorait.",
|
||||
"admin": "Az adminisztrátori műveletek csak bővülő nyilvántartása — ki mit változtatott és mikor. Egy sor kinyitva megmutatja az előtte–utána állapotot és a kérése naplósorait.",
|
||||
"logs": "A szerver tárolt naplója — az API, a letöltő és a böngészők sorai, amelyeket a megőrzési idő végéig tart meg. Egy sor a helyén nyílik ki; a kérés-, feladat- vagy futásazonosítójára kattintva csak az ahhoz tartozó sorok maradnak."
|
||||
},
|
||||
"search": "Keresés…",
|
||||
"windowLabel": "Időtartomány",
|
||||
"windows": {
|
||||
"1h": "1 óra",
|
||||
"24h": "24 óra",
|
||||
"7d": "7 nap",
|
||||
"30d": "30 nap",
|
||||
"90d": "90 nap"
|
||||
},
|
||||
"account": "Fiók",
|
||||
"any": "Bármelyik",
|
||||
"noAccount": "Fiók nélkül (háttér)",
|
||||
"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",
|
||||
"runs": {
|
||||
"job": "Feladat",
|
||||
"statusLabel": "Szűrés állapotra",
|
||||
"status": {
|
||||
"error": "Hibás",
|
||||
"interrupted": "Megszakadt",
|
||||
"skipped": "Kihagyva",
|
||||
"running": "Fut",
|
||||
"ok": "Rendben"
|
||||
},
|
||||
"empty": "Nincs a szűrőknek megfelelő futás.",
|
||||
"count_one": "{{count}} futás látszik · a legújabb elöl",
|
||||
"count_other": "{{count}} futás látszik · a legújabb elöl",
|
||||
"fields": {
|
||||
"started": "Indította",
|
||||
"when": "Kezdés → vége",
|
||||
"target": "Cél",
|
||||
"quota": "Kvótaegység"
|
||||
},
|
||||
"logLines_one": "A futás {{count}} naplósora →",
|
||||
"logLines_other": "A futás {{count}} naplósora →",
|
||||
"jobRuns": "Ennek a feladatnak a futásai →"
|
||||
},
|
||||
"audit": {
|
||||
"action": "Művelet",
|
||||
"target": "Cél",
|
||||
"requestLines": "A kérés naplósorai →",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,7 @@
|
||||
{
|
||||
"title": "Szervernapló",
|
||||
"intro": "A szerver tárolt naplója — az API, a letöltő és a böngészők sorai, amelyeket a megőrzési idő végéig tart meg. Egy sor a helyén nyílik ki; a kérés- vagy feladat-azonosítójára kattintva csak az ahhoz tartozó sorok maradnak.",
|
||||
"search": "Keresés az üzenetben, a kódban, a hívási veremben…",
|
||||
"levelLabel": "Szűrés szintre",
|
||||
"source": "Forrás",
|
||||
"account": "Fiók",
|
||||
"event": "Esemény",
|
||||
"window": "Időszak",
|
||||
"windows": {
|
||||
"1h": "1 óra",
|
||||
"24h": "24 óra",
|
||||
"7d": "7 nap"
|
||||
},
|
||||
"any": "Bármi",
|
||||
"noAccount": "Fiók nélkül",
|
||||
"noEvent": "Kód nélkül",
|
||||
@@ -38,6 +28,9 @@
|
||||
"account": "Fiók",
|
||||
"request": "Kérés",
|
||||
"job": "Feladat",
|
||||
"version": "Verzió"
|
||||
}
|
||||
"version": "Verzió",
|
||||
"run": "Futás"
|
||||
},
|
||||
"pivotRun": "futás: {{id}}",
|
||||
"showRun": "Csak ennek a futásnak a sorai"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"title": "Eseménynapló",
|
||||
"intro": "Az admin-műveletek csak-hozzáfűző naplója — ki, mit, mikor. Művelet-szinten naplózva, soha nem elemenként.",
|
||||
"empty": "Még nincs naplóbejegyzés.",
|
||||
"system": "Rendszer",
|
||||
"clear": "Eseménynapló ürítése",
|
||||
@@ -9,7 +7,6 @@
|
||||
"cleared_one": "{{count}} naplóbejegyzés törölve",
|
||||
"cleared_other": "{{count}} naplóbejegyzés törölve",
|
||||
"clearFailed": "Nem sikerült üríteni az eseménynaplót",
|
||||
"truncated": "A legutóbbi {{returned}} bejegyzés látszik a(z) {{total}}-ból.",
|
||||
"on": "Be",
|
||||
"off": "Ki",
|
||||
"default": "alapérték",
|
||||
@@ -18,7 +15,6 @@
|
||||
"cols": {
|
||||
"when": "Mikor",
|
||||
"actor": "Ki",
|
||||
"action": "Művelet",
|
||||
"details": "Részletek"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,8 @@
|
||||
"signOut": "Kijelentkezés",
|
||||
"addAccount": "Másik fiók hozzáadása",
|
||||
"switchTo": "Váltás erre: {{name}}",
|
||||
"audit": "Eseménynapló",
|
||||
"logs": "Szervernapló",
|
||||
"googleAddress": "Google-fiók: {{email}}"
|
||||
"googleAddress": "Google-fiók: {{email}}",
|
||||
"activity": "Napló"
|
||||
},
|
||||
"sync": {
|
||||
"yours": "tiéd",
|
||||
|
||||
@@ -18,9 +18,8 @@
|
||||
"scheduler": "Ütemező",
|
||||
"config": "Konfig",
|
||||
"users": "Felhasználók",
|
||||
"audit": "Eseménynapló",
|
||||
"admin": "Admin",
|
||||
"logs": "Napló",
|
||||
"settings": "Beállítások"
|
||||
"settings": "Beállítások",
|
||||
"activity": "Napló"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,5 +211,6 @@
|
||||
},
|
||||
"interrupted": "megszakadt: a futás közben újraindult a szerver",
|
||||
"adHocTitle": "Alkalmi futások",
|
||||
"adHocStarted": "felhasználó indítja"
|
||||
"adHocStarted": "felhasználó indítja",
|
||||
"history": "A feladat futásai"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
{
|
||||
"tabs": {
|
||||
"overview": "Áttekintés",
|
||||
"system": "Rendszer"
|
||||
},
|
||||
"title": "API-kvóta használat",
|
||||
"rangeDays": "{{d}}",
|
||||
"rangeLabel": "Időtartomány",
|
||||
"introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ",
|
||||
"introSystem": "Rendszer",
|
||||
"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…",
|
||||
"noData": "Nincs adat.",
|
||||
"dailyTotal": "Napi összesen ({{d}} · {{units}} egység)",
|
||||
"noUsageInRange": "Nincs használat ebben az időszakban.",
|
||||
"dailyTooltip": "{{day}}: {{units}} egység",
|
||||
"system": "Rendszer (háttér)",
|
||||
@@ -49,10 +40,6 @@
|
||||
"syncFailed": "Nem sikerült szinkronizálni a feliratkozásokat",
|
||||
"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",
|
||||
"admin": "Adminisztrátor",
|
||||
"adminPauseHint": "A háttér-szinkronizálás szüneteltetése vagy folytatása az egész alkalmazásra (minden felhasználóra).",
|
||||
"resumeBackgroundSync": "Háttér-szinkronizálás folytatása",
|
||||
"pauseBackgroundSync": "Háttér-szinkronizálás szüneteltetése"
|
||||
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// The activity page's view in the address bar: a link carries the tab and every filter, a hand-edited
|
||||
// value the API would refuse degrades to "no filter", a filter at its default leaves no key behind,
|
||||
// and a link to a page this one replaced opens that page's tab.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ACTIVITY_KEYS,
|
||||
activityParamsToState,
|
||||
activityParamUpdates,
|
||||
DEFAULT_ACTIVITY_STATE,
|
||||
sinceOf,
|
||||
type ActivityUrlState,
|
||||
} from "./activityUrlState";
|
||||
|
||||
const params = (updates: Record<string, string | null>) =>
|
||||
new URLSearchParams(Object.entries(updates).filter((e): e is [string, string] => e[1] !== null));
|
||||
|
||||
describe("activityUrlState", () => {
|
||||
it("round-trips the tab and every filter", () => {
|
||||
const state: ActivityUrlState = {
|
||||
tab: "admin",
|
||||
window: "30d",
|
||||
user: "none",
|
||||
q: "100% done",
|
||||
job: "channel_backfill",
|
||||
status: "interrupted",
|
||||
action: "config_set",
|
||||
};
|
||||
expect(activityParamsToState(params(activityParamUpdates(state)))).toEqual(state);
|
||||
});
|
||||
|
||||
it("leaves no key for a filter at its default, and opens on the runs of the last 7 days", () => {
|
||||
expect(
|
||||
Object.values(activityParamUpdates(DEFAULT_ACTIVITY_STATE)).every((v) => v === null),
|
||||
).toBe(true);
|
||||
expect([DEFAULT_ACTIVITY_STATE.tab, DEFAULT_ACTIVITY_STATE.window]).toEqual(["runs", "7d"]);
|
||||
});
|
||||
|
||||
it("drops a value the API would refuse instead of sending it", () => {
|
||||
const state = activityParamsToState(
|
||||
new URLSearchParams(
|
||||
"at=everything&aw=1y&au=me&rjob=Not-A-Job&rst=finished&aact=DROP%20TABLE",
|
||||
),
|
||||
);
|
||||
expect(state).toEqual(DEFAULT_ACTIVITY_STATE);
|
||||
});
|
||||
|
||||
it("opens the tab of the page a link still names", () => {
|
||||
expect(activityParamsToState(new URLSearchParams("page=audit")).tab).toBe("admin");
|
||||
expect(activityParamsToState(new URLSearchParams("page=logs")).tab).toBe("logs");
|
||||
// A tab the link names wins over the retired page's.
|
||||
expect(activityParamsToState(new URLSearchParams("page=logs&at=quota")).tab).toBe("quota");
|
||||
});
|
||||
|
||||
it("clears its own keys and the server log's on a page change", () => {
|
||||
expect(ACTIVITY_KEYS).toEqual(expect.arrayContaining(["at", "aw", "au", "aq", "rjob", "lreq"]));
|
||||
});
|
||||
|
||||
it("starts the window that many hours back", () => {
|
||||
const now = Date.UTC(2026, 9, 18, 12);
|
||||
expect(sinceOf("24h", now)).toBe("2026-10-17T12:00:00.000Z");
|
||||
expect(sinceOf("90d", now)).toBe("2026-07-20T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// The activity page's view in the address bar (`?page=activity&at=runs&aw=24h&rjob=backfill`), so a
|
||||
// filtered view is a link: which tab, the filters every tab shares (time range, account, text), and
|
||||
// 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 { 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 interface ActivityUrlState {
|
||||
tab: ActivityTab;
|
||||
window: ActivityWindow;
|
||||
/** An account id, or `none` for what no account did (the background). */
|
||||
user: string | null;
|
||||
q: string;
|
||||
/** Runs: one job's runs. */
|
||||
job: string | null;
|
||||
status: RunStatusFilter | null;
|
||||
/** Admin actions: one action. */
|
||||
action: string | null;
|
||||
}
|
||||
|
||||
export const ACTIVITY_TABS: readonly ActivityTab[] = ["runs", "admin", "logs", "quota"];
|
||||
export const ACTIVITY_WINDOWS: readonly ActivityWindow[] = ["1h", "24h", "7d", "30d", "90d"];
|
||||
export const RUN_STATUSES: readonly RunStatusFilter[] = [
|
||||
"error",
|
||||
"interrupted",
|
||||
"skipped",
|
||||
"running",
|
||||
"ok",
|
||||
];
|
||||
|
||||
export const WINDOW_HOURS: Record<ActivityWindow, number> = {
|
||||
"1h": 1,
|
||||
"24h": 24,
|
||||
"7d": 24 * 7,
|
||||
"30d": 24 * 30,
|
||||
"90d": 24 * 90,
|
||||
};
|
||||
|
||||
export const DEFAULT_ACTIVITY_STATE: ActivityUrlState = {
|
||||
tab: "runs",
|
||||
window: "7d",
|
||||
user: null,
|
||||
q: "",
|
||||
job: null,
|
||||
status: null,
|
||||
action: null,
|
||||
};
|
||||
|
||||
const OWN_KEYS = ["at", "aw", "au", "aq", "rjob", "rst", "aact"] as const;
|
||||
export const ACTIVITY_KEYS = [...OWN_KEYS, ...LOGS_KEYS] as const;
|
||||
|
||||
// The pages this one replaced: a link or a bookmark to one opens its tab here (urlState's RETIRED
|
||||
// sends the page id itself here).
|
||||
const RETIRED_TAB: Readonly<Record<string, ActivityTab>> = { audit: "admin", logs: "logs" };
|
||||
|
||||
// The same shapes the API validates: a hand-edited link degrades to "no filter" here instead of to
|
||||
// a 422 on every request the page makes.
|
||||
const USER_RE = /^(none|\d{1,10})$/;
|
||||
const ID_RE = /^[a-z][a-z0-9_]*$/;
|
||||
const Q_MAX = 200;
|
||||
|
||||
const pick = (value: string | null, re: RegExp, max: number) =>
|
||||
value && value.length <= max && re.test(value) ? value : null;
|
||||
const oneOf = <T extends string>(value: string | null, list: readonly T[]): T | null =>
|
||||
list.includes(value as T) ? (value as T) : null;
|
||||
|
||||
export function activityParamsToState(params: URLSearchParams): ActivityUrlState {
|
||||
return {
|
||||
tab:
|
||||
oneOf(params.get("at"), ACTIVITY_TABS) ??
|
||||
RETIRED_TAB[params.get("page") ?? ""] ??
|
||||
DEFAULT_ACTIVITY_STATE.tab,
|
||||
window: oneOf(params.get("aw"), ACTIVITY_WINDOWS) ?? DEFAULT_ACTIVITY_STATE.window,
|
||||
user: pick(params.get("au"), USER_RE, 10),
|
||||
q: (params.get("aq") ?? "").slice(0, Q_MAX),
|
||||
job: pick(params.get("rjob"), ID_RE, 40),
|
||||
status: oneOf(params.get("rst"), RUN_STATUSES),
|
||||
action: pick(params.get("aact"), ID_RE, 48),
|
||||
};
|
||||
}
|
||||
|
||||
/** The URL updates for a state: a filter at its default clears its key. */
|
||||
export function activityParamUpdates(state: ActivityUrlState): Record<string, string | null> {
|
||||
const d = DEFAULT_ACTIVITY_STATE;
|
||||
return {
|
||||
at: state.tab === d.tab ? null : state.tab,
|
||||
aw: state.window === d.window ? null : state.window,
|
||||
au: state.user,
|
||||
aq: state.q.trim() ? state.q : null,
|
||||
rjob: state.job,
|
||||
rst: state.status,
|
||||
aact: state.action,
|
||||
};
|
||||
}
|
||||
|
||||
/** Where the window starts, as the API takes it. */
|
||||
export function sinceOf(window: ActivityWindow, now = Date.now()): string {
|
||||
return new Date(now - WINDOW_HOURS[window] * 3_600_000).toISOString();
|
||||
}
|
||||
@@ -95,18 +95,69 @@ export interface AdminUserAccess extends AdminUserRow {
|
||||
export interface AuditRow {
|
||||
id: number;
|
||||
created_at: string | null;
|
||||
actor: string | null; // email of the acting admin; null = System (scheduler/background)
|
||||
/** The acting admin (address in the page's `accounts`); null = the background. */
|
||||
actor_id: number | null;
|
||||
action: string; // canonical key, localized on the client via auditActions.<action>
|
||||
target_type: string | null;
|
||||
target_id: string | null;
|
||||
summary: string | null;
|
||||
before: Record<string, unknown> | null;
|
||||
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;
|
||||
}
|
||||
export interface AuditListResult {
|
||||
items: AuditRow[];
|
||||
total: number; // total rows in the log (may exceed `items` if capped)
|
||||
returned: number;
|
||||
|
||||
/** A page of a history list (audit log, runs), newest first; `next_cursor` continues it. */
|
||||
export interface HistoryPage<T> {
|
||||
items: T[];
|
||||
/** The accounts this page names, by id; an erased one is absent. */
|
||||
accounts: Record<string, string>;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
/** The filters every activity tab shares, as the APIs take them. */
|
||||
export interface SharedQuery {
|
||||
/** An account id, or `none` for what no account did. */
|
||||
user?: string;
|
||||
q?: string;
|
||||
since?: string;
|
||||
}
|
||||
|
||||
export interface AuditQuery extends SharedQuery {
|
||||
action?: string;
|
||||
}
|
||||
|
||||
/** One background run (`job_runs`), as the activity page lists it. */
|
||||
export interface RunRow {
|
||||
id: number;
|
||||
job: string;
|
||||
trigger: RunBrief["trigger"];
|
||||
actor_id: number | null;
|
||||
/** What a user's ad-hoc run worked on (a channel id), and that channel's title. */
|
||||
target: string | null;
|
||||
target_title: string | null;
|
||||
status: RunBrief["status"];
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
summary: string | null;
|
||||
result: JobResult | null;
|
||||
error: string | null;
|
||||
failure: { code: string; [key: string]: unknown } | null;
|
||||
quota_units: number;
|
||||
/** How many stored log lines the run left (WARNING and up only). */
|
||||
log_lines: number;
|
||||
}
|
||||
|
||||
export interface RunQuery extends SharedQuery {
|
||||
job?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** The job menu and the status chips, each counted under every filter but its own. */
|
||||
export interface RunFacets {
|
||||
jobs: LogFacet[];
|
||||
statuses: LogFacet[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** One stored log line (`log_events`), as the diagnostics console reads it. Values the server's
|
||||
@@ -124,6 +175,7 @@ export interface ServerLogRow {
|
||||
request_id: string | null;
|
||||
user_id: number | null;
|
||||
job_id: number | null;
|
||||
run_id: number | null;
|
||||
debug_session_id: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
@@ -136,6 +188,7 @@ export interface LogQuery {
|
||||
event?: string;
|
||||
request_id?: string;
|
||||
job_id?: number;
|
||||
run_id?: number;
|
||||
q?: string;
|
||||
since?: string;
|
||||
}
|
||||
@@ -171,6 +224,7 @@ function logParams(query: LogQuery, extra: Record<string, string | number | null
|
||||
event: query.event,
|
||||
request_id: query.request_id,
|
||||
job_id: query.job_id,
|
||||
run_id: query.run_id,
|
||||
q: query.q,
|
||||
since: query.since,
|
||||
...extra,
|
||||
@@ -182,6 +236,16 @@ function logParams(query: LogQuery, extra: Record<string, string | number | null
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
/** `?a=1&b=x` from the set values (an empty string or null is left out), or "" when none is. */
|
||||
function queryString(values: object): string {
|
||||
const p = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(values) as [string, unknown][]) {
|
||||
if (value !== undefined && value !== null && value !== "") p.set(key, String(value));
|
||||
}
|
||||
const s = p.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
export type JobResult = { [key: string]: number | string | JobResult };
|
||||
|
||||
/** How a run ended — the server's `RunStatus` (app/runstatus.py) minus `running`. `interrupted`:
|
||||
@@ -277,7 +341,14 @@ export const adminApi = {
|
||||
testIssues: (): Promise<{ ok: true; repo: string; url: string | null }> =>
|
||||
req("/api/admin/config/test-issues", { method: "POST" }),
|
||||
// --- users & roles ---
|
||||
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
|
||||
adminAudit: (query: AuditQuery, before: string | null): Promise<HistoryPage<AuditRow>> =>
|
||||
req(`/api/admin/audit${queryString({ ...query, before })}`),
|
||||
adminAuditFacets: (query: AuditQuery): Promise<{ actions: LogFacet[] }> =>
|
||||
req(`/api/admin/audit/facets${queryString(query)}`),
|
||||
adminRuns: (query: RunQuery, before: string | null): Promise<HistoryPage<RunRow>> =>
|
||||
req(`/api/admin/runs${queryString({ ...query, before })}`),
|
||||
adminRunFacets: (query: RunQuery): Promise<RunFacets> =>
|
||||
req(`/api/admin/runs/facets${queryString(query)}`),
|
||||
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
|
||||
// --- diagnostics console ---
|
||||
adminLogs: (query: LogQuery, before: string | null): Promise<LogPage> =>
|
||||
|
||||
@@ -18,12 +18,13 @@ export interface AdminQuotaRow {
|
||||
}
|
||||
|
||||
export interface AdminQuota {
|
||||
range_days: number;
|
||||
rows: AdminQuotaRow[];
|
||||
daily: { day: string; total: number }[];
|
||||
}
|
||||
|
||||
export const quotaApi = {
|
||||
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
||||
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
||||
/** Spend from `since` to now, optionally for one account (`none` = the background). */
|
||||
adminQuota: (since: string, user: string | null): Promise<AdminQuota> =>
|
||||
req(`/api/quota/admin?${new URLSearchParams({ since, ...(user ? { user } : {}) }).toString()}`),
|
||||
};
|
||||
|
||||
+6
-10
@@ -4,37 +4,33 @@
|
||||
// min/run", "Background sync paused") reads in the reader's language, rebuilt from the row's
|
||||
// data through the REAL catalog. A row whose summary is data (an e-mail, a URL) keeps it.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import i18n, { setLanguage } from "../i18n";
|
||||
import type { AuditRow } from "../lib/api";
|
||||
import { auditColumns } from "./auditColumns";
|
||||
import type { AuditRow } from "./api";
|
||||
import { auditText } from "./auditText";
|
||||
|
||||
// Unmount BEFORE the language goes back: a mounted reader re-renders on the switch, and under a loaded
|
||||
// run that render landed after the test environment was gone ("window is not defined").
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setLanguage("en");
|
||||
});
|
||||
|
||||
const row = (over: Partial<AuditRow>): AuditRow => ({
|
||||
id: 1,
|
||||
created_at: null,
|
||||
actor: null,
|
||||
actor_id: null,
|
||||
action: "sync_pause",
|
||||
target_type: null,
|
||||
target_id: null,
|
||||
summary: null,
|
||||
before: null,
|
||||
after: null,
|
||||
request_id: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
function details(r: AuditRow): string {
|
||||
setLanguage("hu");
|
||||
const t = i18n.getFixedT("hu");
|
||||
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
|
||||
const { container } = render(<>{col.render!(r)}</>);
|
||||
return [...container.querySelectorAll(".wrap-break-word")].map((e) => e.textContent).join(" | ");
|
||||
const { head, sub } = auditText(t, r, "hu");
|
||||
return [head, sub].filter(Boolean).join(" | ");
|
||||
}
|
||||
|
||||
describe("an audit row's details", () => {
|
||||
@@ -1,15 +1,7 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AuditRow } from "../lib/api";
|
||||
import { formatNumber } from "../lib/format";
|
||||
import type { JobResult } from "../lib/api";
|
||||
import { jobResultText } from "../lib/jobResult";
|
||||
import type { Column } from "./ui/DataTable";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
|
||||
function actionLabel(t: TFunction, action: string): string {
|
||||
// Canonical key → localized label; falls back to the raw key for any unmapped action.
|
||||
return t(`auditActions.${action}`, action);
|
||||
}
|
||||
import type { AuditRow, JobResult } from "./api";
|
||||
import { formatNumber } from "./format";
|
||||
import { jobResultText } from "./jobResult";
|
||||
|
||||
function fmt(v: unknown): string {
|
||||
return v == null || v === "" ? "—" : String(v);
|
||||
@@ -90,64 +82,18 @@ function changeText(row: AuditRow): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Columns for the admin audit-log DataTable. `actionOptions` are the distinct actions present in
|
||||
* the data (built by the page) so the Action column's select filter only offers real values. */
|
||||
export function auditColumns(
|
||||
/** An audit row in the reader's language: `head` names what changed (a config field, a job; else
|
||||
* the server's summary), `sub` the change itself (before → after, a count). Either may be null. */
|
||||
export function auditText(
|
||||
t: TFunction,
|
||||
row: AuditRow,
|
||||
lang: string,
|
||||
actionOptions: { value: string; label: string }[],
|
||||
): Column<AuditRow>[] {
|
||||
return [
|
||||
{
|
||||
key: "when",
|
||||
header: t("audit.cols.when"),
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
sortValue: (r) => r.created_at ?? "",
|
||||
cardPrimary: true,
|
||||
render: (r) => (
|
||||
<RelativeTime at={r.created_at} seconds empty="—" className="text-muted tabular-nums" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actor",
|
||||
header: t("audit.cols.actor"),
|
||||
sortable: true,
|
||||
sortValue: (r) => r.actor ?? "",
|
||||
filter: { kind: "text", get: (r) => r.actor ?? t("audit.system") },
|
||||
render: (r) =>
|
||||
r.actor ? (
|
||||
<span className="truncate">{r.actor}</span>
|
||||
) : (
|
||||
<span className="text-muted italic">{t("audit.system")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
header: t("audit.cols.action"),
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
sortValue: (r) => actionLabel(t, r.action),
|
||||
filter: { kind: "select", options: actionOptions, test: (r, v) => r.action === v },
|
||||
render: (r) => <span className="font-medium">{actionLabel(t, r.action)}</span>,
|
||||
},
|
||||
{
|
||||
key: "details",
|
||||
header: t("audit.cols.details"),
|
||||
render: (r) => {
|
||||
const local = localDetails(t, r, lang);
|
||||
const summary = local ? local.head : r.summary;
|
||||
const change = local ? local.sub : changeText(r);
|
||||
return (
|
||||
// wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
|
||||
// sizes to its longest row, so one long summary/change stretched the whole table past the
|
||||
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
|
||||
<div className="min-w-0">
|
||||
{summary && <div className="wrap-break-word">{summary}</div>}
|
||||
{change && <div className="text-2xs text-muted wrap-break-word">{change}</div>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
): { head: string | null; sub: string | null } {
|
||||
const local = localDetails(t, row, lang);
|
||||
return local ?? { head: row.summary, sub: changeText(row) };
|
||||
}
|
||||
|
||||
export function auditActionLabel(t: TFunction, action: string): string {
|
||||
// Canonical key → localized label; falls back to the raw key for any unmapped action.
|
||||
return t(`auditActions.${action}`, action);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const row = (over: Partial<ServerLogRow>): ServerLogRow => ({
|
||||
request_id: null,
|
||||
user_id: 1,
|
||||
job_id: null,
|
||||
run_id: null,
|
||||
debug_session_id: "0123456789abcdef",
|
||||
version: null,
|
||||
...over,
|
||||
|
||||
@@ -17,12 +17,10 @@ describe("logsUrlState", () => {
|
||||
const state: LogsUrlState = {
|
||||
group: "error",
|
||||
sources: ["worker"],
|
||||
user: "none",
|
||||
event: "download.job.failed",
|
||||
requestId: "req-abcdef12",
|
||||
jobId: 42,
|
||||
q: "100% done",
|
||||
window: "7d",
|
||||
runId: 7,
|
||||
};
|
||||
expect(logsParamsToState(params(logsParamUpdates(state)))).toEqual(state);
|
||||
});
|
||||
@@ -33,9 +31,7 @@ describe("logsUrlState", () => {
|
||||
|
||||
it("drops a value the API would refuse instead of sending it", () => {
|
||||
const state = logsParamsToState(
|
||||
new URLSearchParams(
|
||||
"lgrp=loud&lsrc=api,WORKER,x y&lu=me&lev=Not.A.Code&lreq=short&ljob=0&lwin=1y",
|
||||
),
|
||||
new URLSearchParams("lgrp=loud&lsrc=api,WORKER,x y&lev=Not.A.Code&lreq=short&ljob=0&lrun=-1"),
|
||||
);
|
||||
expect(state).toEqual({ ...DEFAULT_LOGS_STATE, sources: ["api"] });
|
||||
});
|
||||
|
||||
@@ -1,67 +1,56 @@
|
||||
// The admin diagnostics console's filters in the address bar (`?page=logs&lgrp=error&lreq=…`), so a
|
||||
// filtered view — a request's lines, say — is a link. Keys are namespaced (`l…`) like the Plex and
|
||||
// Channels ones; setPage clears them (LOGS_KEYS) and the console re-mirrors them while it is open.
|
||||
// The server-log tab's own filters in the address bar (`?page=activity&at=logs&lgrp=error&lreq=…`),
|
||||
// so a filtered view — a request's lines, say — is a link. The filters every activity tab shares
|
||||
// (time range, account, text) live in ./activityUrlState. Keys are namespaced (`l…`) like the Plex
|
||||
// and Channels ones; setPage clears them (via ACTIVITY_KEYS) and the tab re-mirrors them while open.
|
||||
import type { LevelGroup } from "./log";
|
||||
|
||||
export type LogWindow = "1h" | "24h" | "7d";
|
||||
|
||||
export interface LogsUrlState {
|
||||
group: LevelGroup;
|
||||
sources: string[];
|
||||
/** An account id, or `none` for the rows without one. */
|
||||
user: string | null;
|
||||
/** An event code, or `none` for the rows without one. */
|
||||
event: string | null;
|
||||
requestId: string | null;
|
||||
jobId: number | null;
|
||||
q: string;
|
||||
window: LogWindow;
|
||||
/** A background run's lines (`job_runs.id`). */
|
||||
runId: number | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_LOGS_STATE: LogsUrlState = {
|
||||
group: "all",
|
||||
sources: [],
|
||||
user: null,
|
||||
event: null,
|
||||
requestId: null,
|
||||
jobId: null,
|
||||
q: "",
|
||||
window: "24h",
|
||||
runId: null,
|
||||
};
|
||||
|
||||
export const LOGS_KEYS = ["lgrp", "lsrc", "lu", "lev", "lreq", "ljob", "lq", "lwin"] as const;
|
||||
|
||||
export const WINDOW_HOURS: Record<LogWindow, number> = { "1h": 1, "24h": 24, "7d": 24 * 7 };
|
||||
export const LOGS_KEYS = ["lgrp", "lsrc", "lev", "lreq", "ljob", "lrun"] as const;
|
||||
|
||||
const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"];
|
||||
// The same shapes the API validates: a link someone edited by hand degrades to "no filter" here
|
||||
// instead of to a 422 on every request the console makes.
|
||||
const USER_RE = /^(none|\d{1,10})$/;
|
||||
const EVENT_RE = /^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$/;
|
||||
const REQUEST_RE = /^[A-Za-z0-9._-]{8,64}$/;
|
||||
const SOURCE_RE = /^[a-z]{1,16}$/;
|
||||
const SOURCES_MAX = 8;
|
||||
const Q_MAX = 200;
|
||||
|
||||
const pick = (value: string | null, re: RegExp, max = 64) =>
|
||||
value && value.length <= max && re.test(value) ? value : null;
|
||||
const positive = (value: string | null) =>
|
||||
value && /^\d{1,10}$/.test(value) && Number(value) > 0 ? Number(value) : null;
|
||||
|
||||
export function logsParamsToState(params: URLSearchParams): LogsUrlState {
|
||||
const group = params.get("lgrp");
|
||||
const window = params.get("lwin");
|
||||
const job = params.get("ljob");
|
||||
return {
|
||||
group: GROUPS.includes(group as LevelGroup) ? (group as LevelGroup) : DEFAULT_LOGS_STATE.group,
|
||||
sources: (params.get("lsrc") ?? "")
|
||||
.split(",")
|
||||
.filter((s) => SOURCE_RE.test(s))
|
||||
.slice(0, SOURCES_MAX),
|
||||
user: pick(params.get("lu"), USER_RE),
|
||||
event: pick(params.get("lev"), EVENT_RE),
|
||||
requestId: pick(params.get("lreq"), REQUEST_RE),
|
||||
jobId: job && /^\d{1,10}$/.test(job) && Number(job) > 0 ? Number(job) : null,
|
||||
q: (params.get("lq") ?? "").slice(0, Q_MAX),
|
||||
window: window && window in WINDOW_HOURS ? (window as LogWindow) : DEFAULT_LOGS_STATE.window,
|
||||
jobId: positive(params.get("ljob")),
|
||||
runId: positive(params.get("lrun")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,11 +59,9 @@ export function logsParamUpdates(state: LogsUrlState): Record<string, string | n
|
||||
return {
|
||||
lgrp: state.group === DEFAULT_LOGS_STATE.group ? null : state.group,
|
||||
lsrc: state.sources.length ? state.sources.join(",") : null,
|
||||
lu: state.user,
|
||||
lev: state.event,
|
||||
lreq: state.requestId,
|
||||
ljob: state.jobId === null ? null : String(state.jobId),
|
||||
lq: state.q.trim() ? state.q : null,
|
||||
lwin: state.window === DEFAULT_LOGS_STATE.window ? null : state.window,
|
||||
lrun: state.runId === null ? null : String(state.runId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Home,
|
||||
ListVideo,
|
||||
ScrollText,
|
||||
FileSearch,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
@@ -129,23 +128,14 @@ const MODULES: readonly ModuleDef[] = [
|
||||
gate: isAdmin,
|
||||
},
|
||||
{
|
||||
id: "audit",
|
||||
labelKey: "header.account.audit",
|
||||
railKey: "nav.rail.audit",
|
||||
titleKey: "audit.title",
|
||||
id: "activity",
|
||||
labelKey: "header.account.activity",
|
||||
railKey: "nav.rail.activity",
|
||||
titleKey: "activity.title",
|
||||
icon: ScrollText,
|
||||
system: true,
|
||||
gate: isAdmin,
|
||||
},
|
||||
{
|
||||
id: "logs",
|
||||
labelKey: "header.account.logs",
|
||||
railKey: "nav.rail.logs",
|
||||
titleKey: "adminLogs.title",
|
||||
icon: FileSearch,
|
||||
system: true,
|
||||
gate: isAdmin,
|
||||
},
|
||||
];
|
||||
|
||||
// Settings is reachable (its own rail button + the ◀/▶ wrap target) but is not a primary module in
|
||||
|
||||
@@ -17,8 +17,7 @@ const EXPECTED: Record<Page, string> = {
|
||||
scheduler: "header.scheduler",
|
||||
config: "header.configuration",
|
||||
users: "header.users",
|
||||
audit: "audit.title",
|
||||
logs: "adminLogs.title",
|
||||
activity: "activity.title",
|
||||
settings: "settings.title",
|
||||
};
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("qk — single-id and scalar keys", () => {
|
||||
expect(qk.downloadLinks(5)).toEqual(["download-links", 5]);
|
||||
expect(qk.dlQuota(3)).toEqual(["dl-quota", 3]);
|
||||
expect(qk.storyboard(5)).toEqual(["storyboard", 5]);
|
||||
expect(qk.adminQuota(30)).toEqual(["admin-quota", 30]);
|
||||
expect(qk.adminQuota({ window: "7d" })).toEqual(["admin-quota", { window: "7d" }]);
|
||||
expect(qk.plexPeopleCards("both", ["Actor"])).toEqual(["plex-people-cards", "both", ["Actor"]]);
|
||||
});
|
||||
|
||||
|
||||
@@ -87,7 +87,10 @@ export const qk = {
|
||||
adminInvites: () => ["admin-invites"] as const,
|
||||
adminPendingAccess: () => ["admin-pending-access"] as const,
|
||||
adminConfig: () => ["admin-config"] as const,
|
||||
adminAudit: () => ["admin-audit"] as const,
|
||||
adminAudit: (filters: object) => ["admin-audit", filters] as const,
|
||||
adminAuditFacets: (filters: object) => ["admin-audit-facets", filters] 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
|
||||
// window it came from is.
|
||||
adminLogs: (filters: object) => ["admin-logs", filters] as const,
|
||||
@@ -105,7 +108,7 @@ export const qk = {
|
||||
debugSessionPreview: (id: string, text: string) =>
|
||||
["debug-sessions", id, "preview", text] as const,
|
||||
adminDownloads: () => ["admin-downloads"] as const,
|
||||
adminQuota: (days: number) => ["admin-quota", days] as const,
|
||||
adminQuota: (filters: object) => ["admin-quota", filters] as const,
|
||||
|
||||
// --- plex ---
|
||||
// The composite plex keys carry a heterogeneous arg list (a filters object, a ratingKeys
|
||||
|
||||
@@ -64,12 +64,10 @@ export const LS = {
|
||||
// before the subscription list has landed. A cache, rewritten from the list on every load.
|
||||
channelNames: "siftlode.channelNames",
|
||||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||||
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and
|
||||
// statsTab below.
|
||||
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab
|
||||
// below.
|
||||
settingsTab: "siftlode.settingsTab",
|
||||
configTab: "siftlode.configTab",
|
||||
auditTable: "siftlode.auditTable",
|
||||
statsTab: "siftlode.statsTab",
|
||||
adminUsersTab: "siftlode.adminUsersTab",
|
||||
filterCollapsed: "siftlode.filterCollapsed",
|
||||
playlist: "siftlode.playlist",
|
||||
|
||||
@@ -128,8 +128,7 @@ const PAGES = [
|
||||
"scheduler",
|
||||
"config",
|
||||
"users",
|
||||
"audit",
|
||||
"logs",
|
||||
"activity",
|
||||
"notifications",
|
||||
"files",
|
||||
"plex",
|
||||
@@ -148,6 +147,10 @@ function isPage(p: string | null | undefined): p is Page {
|
||||
const RETIRED: Readonly<Record<string, Page>> = {
|
||||
// The download queue became the tray; what it downloaded lives in Files.
|
||||
downloads: "files",
|
||||
// The audit log and the server log became tabs of the activity page (activityUrlState opens the
|
||||
// right tab from the old page id).
|
||||
audit: "activity",
|
||||
logs: "activity",
|
||||
};
|
||||
|
||||
/** A page id as stored or linked: a live page, a retired page's successor, else null. */
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from "react";
|
||||
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { HistoryPage } from "./api";
|
||||
|
||||
/** A history list read a page at a time, newest first (the server's keyset paging): the rows loaded
|
||||
* so far, the accounts they name, and whether there is an older page to ask for. While a filter
|
||||
* change loads, the previous filter's rows stay on screen (dimmed by the caller) and no older page is
|
||||
* offered — the cursor on screen belongs to the rows of the old filter. */
|
||||
export function useHistoryList<T>(
|
||||
queryKey: readonly unknown[],
|
||||
fetchPage: (before: string | null) => Promise<HistoryPage<T>>,
|
||||
) {
|
||||
const list = useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn: ({ pageParam }) => fetchPage(pageParam),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const rows = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
|
||||
const accounts = useMemo(
|
||||
() =>
|
||||
Object.assign({}, ...(list.data?.pages.map((p) => p.accounts) ?? [])) as Record<
|
||||
string,
|
||||
string
|
||||
>,
|
||||
[list.data],
|
||||
);
|
||||
return { list, rows, accounts, hasOlder: !list.isPlaceholderData && list.hasNextPage };
|
||||
}
|
||||
|
||||
export type HistoryList<T> = ReturnType<typeof useHistoryList<T>>;
|
||||
Reference in New Issue
Block a user