import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient, } from "@tanstack/react-query"; 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 { nextPageOnClick } from "../lib/nextPage"; import { LEVEL_GROUPS, type LevelGroup } from "../lib/log"; import { sinceOf } from "../lib/activityUrlState"; import { logsParamsToState, logsParamUpdates, type LogsUrlState } from "../lib/logsUrlState"; import { notify } from "../lib/notifications"; import { qk } from "../lib/queryKeys"; import { useUrlMirror } from "../lib/useUrlMirror"; import { HistoryFrame } from "./HistoryList"; import LogLine from "./LogLine"; import { Button } from "./ui/Button"; import FilterChipGroup from "./ui/FilterChipGroup"; import FilterSelect from "./ui/FilterSelect"; import { FilterFoldButton } from "./ui/FilterFold"; import { useFilterFold } from "../lib/useFilterFold"; import { DismissibleChip } from "./ui/Chip"; import type { Shared } from "./ActivityRuns"; // 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"]; function toQuery(state: LogsUrlState, shared: Shared): LogQuery { return { levels: state.group === "all" ? undefined : LEVEL_GROUPS[state.group], sources: state.sources, user: shared.user ?? undefined, event: state.event ?? undefined, request_id: state.requestId ?? undefined, job_id: state.jobId ?? undefined, run_id: state.runId ?? undefined, q: shared.q.trim() || undefined, }; } /** The ids a line can narrow the list to. */ export type LogPivot = Pick; export default function AdminLogs({ shared, landed, onPivot, }: { shared: Shared; /** The filters of the entry Back/Forward landed on (the page reads the address bar, once). */ landed: { logs: LogsUrlState } | null; /** 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(); const [state, setState] = useState(() => logsParamsToState(new URLSearchParams(window.location.search)), ); const [live, setLiveState] = useState(false); // The facet row folds away when the screen is short. const fold = useFilterFold(); 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. const liveRef = useRef(false); const setLive = (on: boolean) => { liveRef.current = on; setLiveState(on); }; const [open, setOpen] = useState(null); useUrlMirror(logsParamUpdates(state)); // Back/Forward lands on another entry's filters; a dialog closing lands on these same ones, and // changes nothing (Live and the open line stay). Adopted during the render that brings it, with // the page's half. const [seen, setSeen] = useState(landed); if (landed !== seen) { setSeen(landed); if (landed && JSON.stringify(landed.logs) !== JSON.stringify(state)) { setOpen(null); setState(landed.logs); } } const update = (patch: Partial) => { setOpen(null); setState((s) => ({ ...s, ...patch })); }; 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(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: shared.window }), queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(shared.window) }), placeholderData: keepPreviousData, refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS * 4 : false), }); // A failed read stops the polling (the intervals above); the button shows that, and pressing it // again reads now. const polling = live && !logs.isError; // What "Load older" continues from — read off the data, since `hasNextPage` is off while live; not // off placeholder rows, which are the previous filter's. const olderCursor = logs.isPlaceholderData ? null : (logs.data?.pages[logs.data.pages.length - 1]?.next_cursor ?? null); // A read that failed while lines were on screen: they are kept, and the message says what the // failure means for them. (With no lines, the list's own error state says it.) Without the facets // the Source and Event menus offer only "Any", as on the other tabs, so the same words say it. const failure = !logs.data ? null : logs.isFetchNextPageError ? "adminLogs.olderFailed" : logs.isError || (facets.isError && facets.data) ? "adminLogs.stale" : facets.isError ? "activity.filtersFailed" : null; const retryFailed = () => { if (logs.isFetchNextPageError) void logs.fetchNextPage(nextPageOnClick(logs.isFetchingNextPage)); else if (logs.isError) void logs.refetch(); if (facets.isError) void facets.refetch(); }; const rows = useMemo(() => logs.data?.pages.flatMap((p) => p.items) ?? [], [logs.data]); const accounts = useMemo( () => Object.assign( {}, facets.data?.accounts, ...(logs.data?.pages.map((p) => p.accounts) ?? []), ) as Record, [facets.data, logs.data], ); const accountLabel = (id: string) => id === "none" ? t("adminLogs.noAccount") : (accounts[id] ?? `#${id}`); const toggleLive = () => { if (polling) { setLive(false); return; } // Watching the top: drop the older pages now; `liveRef` keeps each tick to the first page. qc.setQueryData(key, (d) => d ? { pages: d.pages.slice(0, 1), pageParams: d.pageParams.slice(0, 1) } : d, ); setLive(true); if (logs.isError) void logs.refetch(); if (facets.isError) void facets.refetch(); }; const loadOlder = () => { setLive(false); void logs.fetchNextPage(nextPageOnClick(logs.isFetchingNextPage)); }; const copy = async () => { // Oldest first, as a log reads. const text = [...rows] .reverse() .map((r) => JSON.stringify(r)) .join("\n"); const ok = await copyText(text); notify( ok ? { level: "success", message: t("adminLogs.copied", { count: rows.length }) } : { level: "error", message: t("adminLogs.copyFailed") }, ); }; const groupCount = (group: LevelGroup) => { const levels = facets.data?.levels ?? []; const sum = (list: readonly LogFacet[]) => list.reduce((n, f) => n + f.count, 0); if (group === "all") return facets.data?.total; const inGroup: readonly string[] = LEVEL_GROUPS[group]; return facets.data ? sum(levels.filter((f) => inGroup.includes(f.value))) : undefined; }; const pivot = (patch: Partial) => { setLive(false); 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 // other filters it may count nothing, and a menu without it would show no way back. const facetOptions = ( list: readonly LogFacet[] | undefined, selected: string | null, label: (v: string) => string, ) => { const facets = list ?? []; const kept = selected && !facets.some((f) => f.value === selected) ? [{ value: selected, count: 0 }] : []; return [ { value: "", label: t("adminLogs.any") }, ...[...facets, ...kept].map((f) => ({ value: f.value, label: label(f.value), count: f.count, })), ]; }; return (
update({ group })} options={GROUPS.map((g) => ({ value: g, label: t(`settings.myLog.filters.${g}`), count: groupCount(g), }))} />
{!fold.folded && ( <> v)} selected={[state.sources[0] ?? ""]} onChange={([source]) => update({ sources: source ? [source] : [] })} testId="logs-source" /> v === "none" ? t("adminLogs.noEvent") : v, )} selected={[state.event ?? ""]} onChange={([event]) => update({ event: event || null })} testId="logs-event" /> )}
{(state.requestId || state.jobId !== null || state.runId !== null) && (
{state.requestId && ( update({ requestId: null })} /> )} {state.jobId !== null && ( update({ jobId: null })} /> )} {state.runId !== null && ( update({ runId: null })} /> )}
)} } > {rows.map((row, index) => ( setOpen(open === row.id ? null : row.id)} > pivot({ requestId })} onJob={(jobId) => pivot({ jobId })} onRun={(runId) => pivot({ runId })} /> ))}
); } function PivotChip({ label, clearLabel, onClear, }: { label: string; clearLabel: string; onClear: () => void; }) { return ( {label} ); } function RowDetail({ row, 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; const pivotCls = "font-mono underline decoration-dotted underline-offset-2 hover:decoration-solid"; return (
{t("adminLogs.fields.when")}
{formatDateTimeSec(row.ts, lang)}
{t("adminLogs.fields.source")}
{row.source} · {row.logger}
{t("adminLogs.fields.account")}
{account ?? "—"}
{row.request_id && ( <>
{t("adminLogs.fields.request")}
)} {row.job_id !== null && ( <>
{t("adminLogs.fields.job")}
)} {row.run_id !== null && ( <>
{t("adminLogs.fields.run")}
)} {row.version && ( <>
{t("adminLogs.fields.version")}
{row.version}
)}
{row.msg}
{row.attrs && (
{JSON.stringify(row.attrs, null, 2)}
)} {row.exc &&
{row.exc}
}
); }