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.
191 lines
7.2 KiB
TypeScript
191 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|