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
+36 -67
View File
@@ -3,33 +3,23 @@ import { useTranslation } from "react-i18next";
import { Copy, Search } from "lucide-react";
import { copyText } from "../lib/clipboard";
import { clear, events, subscribe, type Level, type LogEvent } from "../lib/log";
import {
clear,
events,
LEVEL_GROUPS,
subscribe,
type LevelGroup as Filter,
type LogEvent,
} from "../lib/log";
import { notify } from "../lib/notifications";
import LogLine from "./LogLine";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
type Filter = "all" | "error" | "warning" | "info" | "detail";
const FILTER_LEVELS: Record<Exclude<Filter, "all">, readonly Level[]> = {
error: ["ERROR", "CRITICAL"],
warning: ["WARNING"],
info: ["INFO"],
detail: ["DEBUG", "TRACE"],
};
// Rows rendered at once. The ring holds up to 2000 events; drawing all of them on every new event
// would make the settings page the slowest thing in the app while it is watching the app.
const ROWS_SHOWN = 500;
const LEVEL_TAG: Record<Level, { text: string; className: string }> = {
TRACE: { text: "TRACE", className: "text-muted opacity-80" },
DEBUG: { text: "DEBUG", className: "text-muted" },
INFO: { text: "INFO", className: "text-info" },
WARNING: { text: "WARN", className: "text-warn" },
ERROR: { text: "ERROR", className: "text-danger" },
CRITICAL: { text: "FATAL", className: "text-danger" },
};
/** The ring, live: re-read at most once per animation frame however fast events arrive. */
function useLogEvents(): LogEvent[] {
const [list, setList] = useState(events);
@@ -73,12 +63,6 @@ const matches = (event: LogEvent, needle: string) =>
.toLowerCase()
.includes(needle);
const clock = (ts: string) => {
const d = new Date(ts);
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
};
// "Saját napló" — this tab's own client log (lib/log.ts), exactly what it would send while a raised
// diagnostics level is on: newest first, filtered by level group and free text, a row opens to its
// correlation id and attributes, and "Copy" puts the FILTERED events on the clipboard as JSON lines
@@ -99,7 +83,7 @@ export default function MyLogViewer() {
detail: 0,
};
for (const e of all) {
for (const [group, levels] of Object.entries(FILTER_LEVELS)) {
for (const [group, levels] of Object.entries(LEVEL_GROUPS)) {
if (levels.includes(e.level)) out[group as Filter] += 1;
}
}
@@ -108,7 +92,7 @@ export default function MyLogViewer() {
const shown = useMemo(() => {
const needle = query.trim().toLowerCase();
const levels = filter === "all" ? null : FILTER_LEVELS[filter];
const levels = filter === "all" ? null : LEVEL_GROUPS[filter];
const out: LogEvent[] = [];
for (let i = all.length - 1; i >= 0; i--) {
const e = all[i]!;
@@ -166,47 +150,32 @@ export default function MyLogViewer() {
{all.length === 0 ? t("settings.myLog.emptyAll") : t("settings.myLog.empty")}
</p>
) : (
shown.slice(0, ROWS_SHOWN).map((e) => {
const tag = LEVEL_TAG[e.level];
const isOpen = open === e;
return (
<div key={rowKey(e)} className="border-b border-border/50 last:border-b-0">
<button
type="button"
aria-expanded={isOpen}
onClick={() => setOpen(isOpen ? null : e)}
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-2 px-3 py-1 text-left hover:bg-card ${
isOpen ? "bg-accent/10" : ""
}`}
>
<span className="tabular-nums text-muted">{clock(e.ts)}</span>
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
{/* A phone has no room for a fourth column: the code leads the message there. */}
<span className="hidden truncate sm:block">{e.event}</span>
<span className="truncate">
<span className="sm:hidden">{e.event} · </span>
{e.msg}
</span>
</button>
{isOpen && (
<pre className="overflow-x-auto whitespace-pre bg-accent/5 px-3 py-2 text-muted">
{JSON.stringify(
{
event: e.event,
msg: e.msg,
request_id: e.request_id,
ts: e.ts,
tab_id: e.tab_id,
attrs: e.attrs,
},
null,
2,
)}
</pre>
shown.slice(0, ROWS_SHOWN).map((e) => (
<LogLine
key={rowKey(e)}
ts={e.ts}
level={e.level}
event={e.event}
msg={e.msg}
open={open === e}
onToggle={() => setOpen(open === e ? null : e)}
>
<pre className="overflow-x-auto whitespace-pre">
{JSON.stringify(
{
event: e.event,
msg: e.msg,
request_id: e.request_id,
ts: e.ts,
tab_id: e.tab_id,
attrs: e.attrs,
},
null,
2,
)}
</div>
);
})
</pre>
</LogLine>
))
)}
</div>