feat(diagnostics): the admin log console

Admin → Diagnostics reads the server's stored log: one list, newest first,
where a line opens in place to its full record (time, source and logger,
account, request and job ids, version, message, attributes, stack). A
request or job id narrows the list to that request or job with every other
filter off, shown as a removable chip. Filters: level group, source,
account, event, period and a free-text search; all of them live in the URL
(`l*` keys, which setPage clears like the Plex and Channels keys), so a
filtered view is a link and Back restores it. "Live" re-reads the newest
page every few seconds while the tab is visible; paging into older rows
turns it off. Copy puts the loaded lines on the clipboard as JSON lines.

The facet counts now honour every filter but their own, as the feed's do:
a request pivot narrows them, and a picked level does not zero its
siblings. A value in use stays offered even when the counts drop it.

"Saját napló" and the console draw a line with one shared component,
`LogLine`, whose phone layout gives the message its own full-width line
(sharing the code's line left it a few characters). The level groups
moved to lib/log.ts for both.

The console's own reads are logged at DEBUG on success, like the
healthcheck: at INFO its live tail filled the log with itself.
This commit is contained in:
2026-09-19 01:54:48 +02:00
parent 5e208353e3
commit b5996a0e56
23 changed files with 1127 additions and 117 deletions
+425
View File
@@ -0,0 +1,425 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { Copy, Radio, Search, X } from "lucide-react";
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
import { copyText } from "../lib/clipboard";
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 { useUrlMirror } from "../lib/useUrlMirror";
import LogLine from "./LogLine";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect";
import { Section } from "./ui/form";
import { LoadingState, StateMessage } from "./ui/QueryState";
// Admin → Diagnosztika: the server's stored log (`log_events`, E22.6). 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.
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<LogsUrlState, "requestId" | "jobId"> = {
...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 } = useTranslation();
const qc = useQueryClient();
const [state, setState] = useState<LogsUrlState>(() =>
logsParamsToState(new URLSearchParams(window.location.search)),
);
const [live, setLive] = useState(false);
const [open, setOpen] = useState<number | null>(null);
const q = useDebounced(state.q, 300);
useUrlMirror(logsParamUpdates(state));
const update = (patch: Partial<LogsUrlState>) => {
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) => last.next_cursor ?? undefined,
placeholderData: keepPreviousData,
refetchInterval: live ? LIVE_MS : false,
});
const facets = useQuery({
queryKey: qk.adminLogFacets({ ...query, window: state.window }),
queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(state.window) }),
placeholderData: keepPreviousData,
refetchInterval: live ? LIVE_MS * 4 : false,
});
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<string, string>,
[facets.data, logs.data],
);
const accountLabel = (id: string) =>
id === "none" ? t("adminLogs.noAccount") : (accounts[id] ?? `#${id}`);
const toggleLive = () => {
if (!live) {
// Watching the top: drop the older pages, or every tick would re-read all of them.
qc.setQueryData<typeof logs.data>(key, (d) =>
d ? { pages: d.pages.slice(0, 1), pageParams: d.pageParams.slice(0, 1) } : d,
);
}
setLive(!live);
};
const loadOlder = () => {
setLive(false);
void logs.fetchNextPage();
};
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: Pick<LogsUrlState, "requestId" | "jobId">) => {
setLive(false);
update({ ...PIVOT_RESET, ...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 (
<div className="p-4 max-w-6xl w-full mx-auto">
<Section card title={t("adminLogs.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("adminLogs.intro")}</p>
<div
data-testid="admin-logs"
className="overflow-hidden rounded-xl border border-border bg-bg"
>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label className="flex min-w-[12rem] flex-1 items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-sm focus-within:border-accent">
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<input
type="search"
value={state.q}
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
placeholder={t("adminLogs.search")}
aria-label={t("adminLogs.search")}
className="min-w-0 flex-1 bg-transparent outline-none"
/>
</label>
<FilterChipGroup
ariaLabel={t("adminLogs.levelLabel")}
value={state.group}
onChange={(group) => update({ group })}
options={GROUPS.map((g) => ({
value: g,
label: t(`settings.myLog.filters.${g}`),
count: groupCount(g),
}))}
/>
</div>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<FilterSelect
label={t("adminLogs.source")}
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
selected={[state.sources[0] ?? ""]}
onChange={([source]) => update({ sources: source ? [source] : [] })}
testId="logs-source"
/>
<FilterSelect
label={t("adminLogs.account")}
options={facetOptions(facets.data?.users, state.user, accountLabel)}
selected={[state.user ?? ""]}
onChange={([user]) => update({ user: user || null })}
testId="logs-user"
/>
<FilterSelect
label={t("adminLogs.event")}
options={facetOptions(facets.data?.events, state.event, (v) =>
v === "none" ? t("adminLogs.noEvent") : v,
)}
selected={[state.event ?? ""]}
onChange={([event]) => update({ event: event || null })}
testId="logs-event"
/>
<FilterSelect
label={t("adminLogs.window")}
options={WINDOWS.map((w) => ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
selected={[state.window]}
onChange={([window]) => update({ window: window as LogWindow })}
testId="logs-window"
/>
<span className="ml-auto flex gap-1.5">
<Button
variant={live ? "outline-accent" : "outline"}
size="xs"
icon={<Radio className="h-3.5 w-3.5" />}
aria-pressed={live}
onClick={toggleLive}
>
{t("adminLogs.live")}
</Button>
<Button
variant="outline"
size="xs"
icon={<Copy className="h-3.5 w-3.5" />}
disabled={rows.length === 0}
onClick={() => void copy()}
>
{t("adminLogs.copy")}
</Button>
</span>
</div>
{(state.requestId || state.jobId !== null) && (
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
{state.requestId && (
<PivotChip
label={t("adminLogs.pivotRequest", { id: state.requestId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ requestId: null })}
/>
)}
{state.jobId !== null && (
<PivotChip
label={t("adminLogs.pivotJob", { id: state.jobId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ jobId: null })}
/>
)}
</div>
)}
<div className="font-mono text-2xs" data-testid="admin-logs-lines">
{logs.isError && !logs.data ? (
<StateMessage tone="error" onRetry={() => void logs.refetch()}>
{t("common.loadError")}
</StateMessage>
) : logs.isLoading ? (
<LoadingState />
) : rows.length === 0 ? (
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
{t("adminLogs.empty")}
</p>
) : (
rows.map((row) => (
<LogLine
key={row.id}
ts={row.ts}
level={row.level}
event={row.event ?? "—"}
msg={row.msg}
title={new Date(row.ts).toLocaleString()}
open={open === row.id}
onToggle={() => setOpen(open === row.id ? null : row.id)}
>
<RowDetail
row={row}
account={row.user_id === null ? null : accountLabel(String(row.user_id))}
onRequest={(requestId) => pivot({ requestId, jobId: null })}
onJob={(jobId) => pivot({ requestId: null, jobId })}
/>
</LogLine>
))
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
<span>{t("adminLogs.count", { count: rows.length })}</span>
{logs.hasNextPage && (
<Button
variant="outline"
size="xs"
disabled={logs.isFetchingNextPage}
onClick={loadOlder}
>
{t("adminLogs.older")}
</Button>
)}
</div>
</div>
</Section>
</div>
);
}
function PivotChip({
label,
clearLabel,
onClear,
}: {
label: string;
clearLabel: string;
onClear: () => void;
}) {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-accent/40 bg-accent/10 py-0.5 pl-2.5 pr-1 text-accent">
<span className="font-mono">{label}</span>
<button
type="button"
onClick={onClear}
aria-label={clearLabel}
title={clearLabel}
className="rounded-full p-0.5 hover:bg-accent/20"
>
<X className="h-3 w-3" aria-hidden />
</button>
</span>
);
}
function RowDetail({
row,
account,
onRequest,
onJob,
}: {
row: ServerLogRow;
account: string | null;
onRequest: (id: string) => void;
onJob: (id: number) => void;
}) {
const { t } = useTranslation();
const pivotCls =
"font-mono text-accent underline decoration-dotted underline-offset-2 hover:decoration-solid";
return (
<div className="flex flex-col gap-2">
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 font-sans text-xs">
<dt>{t("adminLogs.fields.when")}</dt>
<dd className="tabular-nums">{new Date(row.ts).toLocaleString()}</dd>
<dt>{t("adminLogs.fields.source")}</dt>
<dd className="font-mono">
{row.source} · {row.logger}
</dd>
<dt>{t("adminLogs.fields.account")}</dt>
<dd>{account ?? "—"}</dd>
{row.request_id && (
<>
<dt>{t("adminLogs.fields.request")}</dt>
<dd>
<button
type="button"
className={pivotCls}
title={t("adminLogs.showRequest")}
onClick={() => onRequest(row.request_id!)}
>
{row.request_id}
</button>
</dd>
</>
)}
{row.job_id !== null && (
<>
<dt>{t("adminLogs.fields.job")}</dt>
<dd>
<button
type="button"
className={pivotCls}
title={t("adminLogs.showJob")}
onClick={() => onJob(row.job_id!)}
>
{row.job_id}
</button>
</dd>
</>
)}
{row.version && (
<>
<dt>{t("adminLogs.fields.version")}</dt>
<dd className="font-mono">{row.version}</dd>
</>
)}
</dl>
<pre className="overflow-x-auto whitespace-pre-wrap break-words">{row.msg}</pre>
{row.attrs && (
<pre className="overflow-x-auto whitespace-pre">{JSON.stringify(row.attrs, null, 2)}</pre>
)}
{row.exc && <pre className="overflow-x-auto whitespace-pre text-danger/90">{row.exc}</pre>}
</div>
);
}