feat(diagnostics): walk the log lines from the keyboard; name the console "Server log"

Both log viewers put their lines in a NavGrid: j/k (and the arrows) step
from line to line, and Enter opens one exactly as a click does (the line's
button is its `data-nav-open`). A focused line is named by its code and
message.

The console's nav entry is "Szervernapló" / "Server log", its page title:
"Diagnosztika" was already the Settings tab that holds the user's own log,
and two places with one name read as one place.
This commit is contained in:
2026-09-19 02:02:45 +02:00
parent 8cfab498cf
commit 142593083f
6 changed files with 77 additions and 49 deletions
@@ -142,6 +142,17 @@ describe("AdminLogs", () => {
).toBe("true"); ).toBe("true");
}); });
it("the keyboard can walk the lines and open one (NavGrid items)", async () => {
logsMock.mockResolvedValue({ items: [row(2), row(1)], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(2));
const items = lines().map((b) => b.parentElement!);
expect(items.map((el) => el.getAttribute("data-nav-index"))).toEqual(["0", "1"]);
expect(lines().every((b) => b.hasAttribute("data-nav-open"))).toBe(true);
expect(screen.getByTestId("admin-logs-lines").querySelector("[data-nav-list]")).toBeTruthy();
});
it("a failed read is an error with a retry, not an empty log", async () => { it("a failed read is an error with a retry, not an empty log", async () => {
logsMock.mockRejectedValue(new Error("502")); logsMock.mockRejectedValue(new Error("502"));
renderConsole(); renderConsole();
+24 -20
View File
@@ -24,13 +24,14 @@ import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { useUrlMirror } from "../lib/useUrlMirror"; import { useUrlMirror } from "../lib/useUrlMirror";
import LogLine from "./LogLine"; import LogLine from "./LogLine";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup"; import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect"; import FilterSelect from "./ui/FilterSelect";
import { Section } from "./ui/form"; import { Section } from "./ui/form";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
// Admin → Diagnosztika: the server's stored log (`log_events`, E22.6). One list, newest first; a line // Admin → Szervernapló: 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 // 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 // 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 // few seconds while the tab is visible — the newest page only: live is watching the top, so paging
@@ -287,25 +288,28 @@ export default function AdminLogs() {
{t("adminLogs.empty")} {t("adminLogs.empty")}
</p> </p>
) : ( ) : (
rows.map((row) => ( <NavGrid>
<LogLine {rows.map((row, index) => (
key={row.id} <LogLine
ts={row.ts} navIndex={index}
level={row.level} key={row.id}
event={row.event ?? "—"} ts={row.ts}
msg={row.msg} level={row.level}
title={new Date(row.ts).toLocaleString()} event={row.event ?? "—"}
open={open === row.id} msg={row.msg}
onToggle={() => setOpen(open === row.id ? null : row.id)} title={new Date(row.ts).toLocaleString()}
> open={open === row.id}
<RowDetail onToggle={() => setOpen(open === row.id ? null : row.id)}
row={row} >
account={row.user_id === null ? null : accountLabel(String(row.user_id))} <RowDetail
onRequest={(requestId) => pivot({ requestId, jobId: null })} row={row}
onJob={(jobId) => pivot({ requestId: null, jobId })} account={row.user_id === null ? null : accountLabel(String(row.user_id))}
/> onRequest={(requestId) => pivot({ requestId, jobId: null })}
</LogLine> onJob={(jobId) => pivot({ requestId: null, jobId })}
)) />
</LogLine>
))}
</NavGrid>
)} )}
</div> </div>
+10 -1
View File
@@ -1,10 +1,12 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Level } from "../lib/log"; import type { Level } from "../lib/log";
import { navItemAttrs } from "../lib/listNav";
// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin // One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
// diagnostics console (the server's stored log): time, level, event code, message, and the line opens // diagnostics console (the server's stored log): time, level, event code, message, and the line opens
// in place to whatever detail its screen has. One component, so a level reads the same on both. // in place to whatever detail its screen has. One component, so a level reads the same on both.
// Inside a NavGrid the keyboard walks the lines (`j`/`k`) and Enter opens one, as a click does.
const LEVEL_TAG: Record<Level, { text: string; className: string }> = { const LEVEL_TAG: Record<Level, { text: string; className: string }> = {
TRACE: { text: "TRACE", className: "text-muted opacity-80" }, TRACE: { text: "TRACE", className: "text-muted opacity-80" },
@@ -30,6 +32,7 @@ export default function LogLine({
open, open,
onToggle, onToggle,
title, title,
navIndex,
children, children,
}: { }: {
ts: string; ts: string;
@@ -40,14 +43,20 @@ export default function LogLine({
onToggle: () => void; onToggle: () => void;
/** The row button's tooltip — the admin console puts the full date there. */ /** The row button's tooltip — the admin console puts the full date there. */
title?: string; title?: string;
/** The line's place in its NavGrid. */
navIndex: number;
/** The detail shown while the line is open. */ /** The detail shown while the line is open. */
children: ReactNode; children: ReactNode;
}) { }) {
const tag = LEVEL_TAG[level]; const tag = LEVEL_TAG[level];
return ( return (
<div className="border-b border-border/50 last:border-b-0"> <div
className="border-b border-border/50 last:border-b-0"
{...navItemAttrs(navIndex, `${event} ${msg}`)}
>
<button <button
type="button" type="button"
data-nav-open
aria-expanded={open} aria-expanded={open}
onClick={onToggle} onClick={onToggle}
title={title} title={title}
+30 -26
View File
@@ -13,6 +13,7 @@ import {
} from "../lib/log"; } from "../lib/log";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import LogLine from "./LogLine"; import LogLine from "./LogLine";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup"; import FilterChipGroup from "./ui/FilterChipGroup";
@@ -150,32 +151,35 @@ export default function MyLogViewer() {
{all.length === 0 ? t("settings.myLog.emptyAll") : t("settings.myLog.empty")} {all.length === 0 ? t("settings.myLog.emptyAll") : t("settings.myLog.empty")}
</p> </p>
) : ( ) : (
shown.slice(0, ROWS_SHOWN).map((e) => ( <NavGrid>
<LogLine {shown.slice(0, ROWS_SHOWN).map((e, index) => (
key={rowKey(e)} <LogLine
ts={e.ts} navIndex={index}
level={e.level} key={rowKey(e)}
event={e.event} ts={e.ts}
msg={e.msg} level={e.level}
open={open === e} event={e.event}
onToggle={() => setOpen(open === e ? null : e)} msg={e.msg}
> open={open === e}
<pre className="overflow-x-auto whitespace-pre"> onToggle={() => setOpen(open === e ? null : e)}
{JSON.stringify( >
{ <pre className="overflow-x-auto whitespace-pre">
event: e.event, {JSON.stringify(
msg: e.msg, {
request_id: e.request_id, event: e.event,
ts: e.ts, msg: e.msg,
tab_id: e.tab_id, request_id: e.request_id,
attrs: e.attrs, ts: e.ts,
}, tab_id: e.tab_id,
null, attrs: e.attrs,
2, },
)} null,
</pre> 2,
</LogLine> )}
)) </pre>
</LogLine>
))}
</NavGrid>
)} )}
</div> </div>
+1 -1
View File
@@ -35,7 +35,7 @@
"addAccount": "Add another account", "addAccount": "Add another account",
"switchTo": "Switch to {{name}}", "switchTo": "Switch to {{name}}",
"audit": "Audit log", "audit": "Audit log",
"logs": "Diagnostics", "logs": "Server log",
"googleAddress": "Google account: {{email}}" "googleAddress": "Google account: {{email}}"
}, },
"sync": { "sync": {
+1 -1
View File
@@ -35,7 +35,7 @@
"addAccount": "Másik fiók hozzáadása", "addAccount": "Másik fiók hozzáadása",
"switchTo": "Váltás erre: {{name}}", "switchTo": "Váltás erre: {{name}}",
"audit": "Auditnapló", "audit": "Auditnapló",
"logs": "Diagnosztika", "logs": "Szervernapló",
"googleAddress": "Google-fiók: {{email}}" "googleAddress": "Google-fiók: {{email}}"
}, },
"sync": { "sync": {