import clsx from "clsx"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient, } from "@tanstack/react-query"; import { Copy, Radio, Search } 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 { 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"; 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 { 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"; // 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 // 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 = { ...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 { return { levels: state.group === "all" ? undefined : LEVEL_GROUPS[state.group], sources: state.sources, user: state.user ?? undefined, event: state.event ?? undefined, request_id: state.requestId ?? undefined, job_id: state.jobId ?? undefined, q: q.trim() || undefined, }; } export default function AdminLogs() { 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 toolbar sticks at the scroller's top (./ui/stickyTop), and folds its facet row when short. useStickyTop(); const fold = useFilterFold(); const facetFilters = state.sources.length + (state.user ? 1 : 0) + (state.event ? 1 : 0) + (state.window !== DEFAULT_LOGS_STATE.window ? 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); // 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) => { setOpen(null); setState((s) => ({ ...s, ...patch })); }; const query = toQuery(state, q); const key = qk.adminLogs({ ...query, window: state.window }); const logs = useInfiniteQuery({ queryKey: key, queryFn: ({ pageParam }) => api.adminLogs({ ...query, since: sinceOf(state.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) }), 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.) const failure = !logs.data ? null : logs.isFetchNextPageError ? "olderFailed" : logs.isError || (facets.isError && facets.data) ? "stale" : facets.isError ? "countsFailed" : 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; }; // 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) => { setLive(false); const next = { ...state, ...PIVOT_RESET, ...patch }; window.history.pushState( { ...rememberNavPosition(), sfScroll: nextNavId() }, "", urlWithParams(logsParamUpdates(next)), ); notifyNav(); setJumps((n) => n + 1); update(next); }; // "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 (

{t("adminLogs.intro")}

); } function PivotChip({ label, clearLabel, onClear, }: { label: string; clearLabel: string; onClear: () => void; }) { return ( {label} ); } function RowDetail({ row, account, onRequest, onJob, }: { row: ServerLogRow; account: string | null; onRequest: (id: string) => void; onJob: (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.version && ( <>
{t("adminLogs.fields.version")}
{row.version}
)}
{row.msg}
{row.attrs && (
{JSON.stringify(row.attrs, null, 2)}
)} {row.exc &&
{row.exc}
}
); }