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 = { window: "90d", user: null, q: "", }; export default function Activity() { const { t } = useTranslation(); const [state, setState] = useState(() => 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) => 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 (
update({ tab: tab as ActivityTab })} tabs={ACTIVITY_TABS.map((id) => ({ id, label: t(`activity.tabs.${id}`) }))} className="flex flex-wrap gap-1 pb-2" />
{/* The quota tab says what its numbers are itself, beside them. */} {state.tab !== "quota" && (

{t(`activity.intro.${state.tab}`)}

)} {state.tab === "runs" ? ( pivot({ requestId: null, jobId: null, runId })} /> ) : state.tab === "admin" ? ( 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. ) : ( )}
); } /** 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) => 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 (
{state.tab !== "quota" && ( )} onChange({ window: window as ActivityWindow })} options={ACTIVITY_WINDOWS.map((w) => ({ value: w, label: t(`activity.windows.${w}`) }))} /> ({ value: String(u.id), label: u.email })), ...kept, ]} selected={[state.user ?? ""]} onChange={([user]) => onChange({ user: user || null })} testId="activity-user" />
); }