Files
siftlode/frontend/src/components/Activity.tsx
T
peter 24448e7c6f 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.
2026-10-18 03:45:18 +02:00

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>
);
}