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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// An audit row whose server summary is a fixed English line ("log_level = DEBUG", "rss_poll → 15
|
||||
// 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";
|
||||
|
||||
// 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,
|
||||
action: "sync_pause",
|
||||
target_type: null,
|
||||
target_id: null,
|
||||
summary: null,
|
||||
before: null,
|
||||
after: 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(" | ");
|
||||
}
|
||||
|
||||
describe("an audit row's details", () => {
|
||||
it("name a config change's field and values", () => {
|
||||
const set = row({
|
||||
action: "config_set",
|
||||
target_type: "config",
|
||||
target_id: "plex_enabled",
|
||||
summary: "plex_enabled = True",
|
||||
before: { value: false },
|
||||
after: { value: true },
|
||||
});
|
||||
expect(details(set)).toMatch(/ \| Ki → Be$/);
|
||||
expect(details(set)).not.toContain("plex_enabled");
|
||||
const reset = row({
|
||||
action: "config_reset",
|
||||
target_type: "config",
|
||||
target_id: "download_upload_max_bytes",
|
||||
summary: "download_upload_max_bytes → default",
|
||||
before: { value: 5242880 },
|
||||
});
|
||||
expect(details(reset)).toMatch(/ \| 5\s242\s880 → alapérték$/);
|
||||
const secret = row({
|
||||
action: "config_set",
|
||||
target_type: "config",
|
||||
target_id: "youtube_api_key",
|
||||
summary: "youtube_api_key = (secret updated)",
|
||||
});
|
||||
expect(details(secret)).toMatch(/ \| titok frissítve/);
|
||||
});
|
||||
|
||||
it("tell a secret's reset from its update, and name a choice", () => {
|
||||
const reset = row({
|
||||
action: "config_reset",
|
||||
target_type: "config",
|
||||
target_id: "tmdb_api_key",
|
||||
summary: "tmdb_api_key → default",
|
||||
});
|
||||
expect(details(reset)).toMatch(/ \| titok visszaállítva/);
|
||||
const choice = row({
|
||||
action: "config_set",
|
||||
target_type: "config",
|
||||
target_id: "plex_transcode_hw",
|
||||
before: { value: "auto" },
|
||||
after: { value: "on" },
|
||||
});
|
||||
expect(details(choice)).toMatch(/ \| Automatikus → GPU$/);
|
||||
});
|
||||
|
||||
it("name an interval, a batch and a count in the reader's language", () => {
|
||||
expect(
|
||||
details(
|
||||
row({
|
||||
action: "scheduler_interval",
|
||||
target_type: "job",
|
||||
target_id: "rss_poll",
|
||||
summary: "rss_poll → 15 min/run",
|
||||
after: { interval_minutes: 15 },
|
||||
}),
|
||||
),
|
||||
).toBe("RSS-lekérdezés (új feltöltések) | 15 percenként");
|
||||
expect(
|
||||
details(row({ action: "maintenance_tune", after: { revalidate_batch: 50000 } })),
|
||||
).toMatch(/^Futásonként újraellenőrzött videók \| 50\s000$/);
|
||||
expect(
|
||||
details(
|
||||
row({ action: "audit_clear", summary: "Cleared 12 audit entries", after: { cleared: 12 } }),
|
||||
),
|
||||
).not.toContain("Cleared");
|
||||
});
|
||||
|
||||
it("add nothing to a pause, whose action already says it", () => {
|
||||
expect(details(row({ action: "sync_pause", summary: "Background sync paused" }))).toBe("");
|
||||
});
|
||||
|
||||
it("keep a summary that is data", () => {
|
||||
expect(details(row({ action: "invite_add", summary: "someone@example.com" }))).toBe(
|
||||
"someone@example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
function fmt(v: unknown): string {
|
||||
return v == null || v === "" ? "—" : String(v);
|
||||
}
|
||||
|
||||
/** A logged config value in the reader's language: a switch reads on/off, a count is grouped, a
|
||||
* choice is named as the Configuration page names it. */
|
||||
function configValue(t: TFunction, key: string, v: unknown, lang: string): string {
|
||||
if (typeof v === "boolean") return t(v ? "audit.on" : "audit.off");
|
||||
if (typeof v === "number") return formatNumber(v, lang);
|
||||
if (typeof v === "string" && v) return t(`config.choices.${key}.${v}`, { defaultValue: v });
|
||||
return fmt(v);
|
||||
}
|
||||
|
||||
/** A row's `after` as a job-style result (counts), or null when it holds none. */
|
||||
function counts(after: Record<string, unknown>): JobResult | null {
|
||||
const out: JobResult = {};
|
||||
for (const [k, v] of Object.entries(after)) if (typeof v === "number") out[k] = v;
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/** The details of the rows whose server `summary` is a fixed English line, rebuilt from the row's
|
||||
* data in the reader's language: a config change names the field and its values, an interval or batch change its new value, a count-bearing
|
||||
* action its counts; a pause/resume says nothing the action has not. `undefined` = not one of these (or
|
||||
* an older row without the data); the generic summary + change rendering applies. */
|
||||
function localDetails(
|
||||
t: TFunction,
|
||||
row: AuditRow,
|
||||
lang: string,
|
||||
): { head: string | null; sub: string | null } | undefined {
|
||||
const after = row.after ?? {};
|
||||
const before = row.before ?? {};
|
||||
if ((row.action === "config_set" || row.action === "config_reset") && row.target_id) {
|
||||
const key = row.target_id;
|
||||
const head = t(`config.fields.${key}.label`, key);
|
||||
const reset = row.action === "config_reset";
|
||||
// A secret's value is never logged: there is no before/after to show.
|
||||
if (!row.before && !row.after)
|
||||
return { head, sub: t(reset ? "audit.secretReset" : "audit.secretUpdated") };
|
||||
const to = reset ? t("audit.default") : configValue(t, key, after.value, lang);
|
||||
return { head, sub: `${configValue(t, key, before.value, lang)} → ${to}` };
|
||||
}
|
||||
if (
|
||||
row.action === "scheduler_interval" &&
|
||||
row.target_id &&
|
||||
typeof after.interval_minutes === "number"
|
||||
)
|
||||
return {
|
||||
head: t(`scheduler.jobs.${row.target_id}`, row.target_id),
|
||||
sub: t("scheduler.everyMin", { count: after.interval_minutes }),
|
||||
};
|
||||
if (row.action === "maintenance_tune" && typeof after.revalidate_batch === "number")
|
||||
return {
|
||||
head: t("scheduler.maintenance.batchLabel"),
|
||||
sub: formatNumber(after.revalidate_batch, lang),
|
||||
};
|
||||
if (row.action === "audit_clear" || row.action === "discovery_purge") {
|
||||
const result = counts(after);
|
||||
return result ? { head: null, sub: jobResultText(t, result, lang) } : undefined;
|
||||
}
|
||||
if (row.action === "sync_pause" || row.action === "sync_resume") return { head: null, sub: null };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A compact before→after (or after-only) rendering of the JSON snapshots, e.g. "role: user → admin". */
|
||||
function changeText(row: AuditRow): string | null {
|
||||
const { before, after } = row;
|
||||
if (before && after) {
|
||||
return Object.keys(after)
|
||||
.map((k) => `${k}: ${fmt(before[k])} → ${fmt(after[k])}`)
|
||||
.join(", ");
|
||||
}
|
||||
if (after) {
|
||||
return Object.entries(after)
|
||||
.map(([k, v]) => `${k}: ${fmt(v)}`)
|
||||
.join(", ");
|
||||
}
|
||||
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(
|
||||
t: TFunction,
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user