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:
@@ -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,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user