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.
218 lines
7.4 KiB
TypeScript
218 lines
7.4 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Copy, Search } from "lucide-react";
|
|
|
|
import { copyText } from "../lib/clipboard";
|
|
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 NavGrid from "./ui/NavGrid";
|
|
import { Button } from "./ui/Button";
|
|
import FilterChipGroup from "./ui/FilterChipGroup";
|
|
|
|
// 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;
|
|
|
|
/** The ring, live: re-read at most once per animation frame however fast events arrive. */
|
|
function useLogEvents(): LogEvent[] {
|
|
const [list, setList] = useState(events);
|
|
useEffect(() => {
|
|
let frame: number | null = null;
|
|
const unsubscribe = subscribe(() => {
|
|
if (frame !== null) return;
|
|
frame = window.requestAnimationFrame(() => {
|
|
frame = null;
|
|
setList(events());
|
|
});
|
|
});
|
|
setList(events()); // whatever arrived between the first render and the subscription
|
|
return () => {
|
|
unsubscribe();
|
|
if (frame !== null) window.cancelAnimationFrame(frame);
|
|
};
|
|
}, []);
|
|
return list;
|
|
}
|
|
|
|
// A row's React key: the event OBJECT's own number, handed out the first time it is drawn. The ring
|
|
// keeps the same objects, so the key stays put as the list grows at the top — an index key shifted
|
|
// every row on every new event, remounting all of them and dropping the focus off the row button
|
|
// (at Részletes a click on a row logs a line itself, so it happened on every click).
|
|
const rowIds = new WeakMap<LogEvent, number>();
|
|
let nextRowId = 0;
|
|
const rowKey = (event: LogEvent) => {
|
|
let id = rowIds.get(event);
|
|
if (id === undefined) {
|
|
id = nextRowId++;
|
|
rowIds.set(event, id);
|
|
}
|
|
return id;
|
|
};
|
|
|
|
const matches = (event: LogEvent, needle: string) =>
|
|
!needle ||
|
|
[event.event, event.msg, event.request_id ?? "", event.attrs ? JSON.stringify(event.attrs) : ""]
|
|
.join(" ")
|
|
.toLowerCase()
|
|
.includes(needle);
|
|
|
|
// "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
|
|
// (one event per line) for a bug report.
|
|
export default function MyLogViewer() {
|
|
const { t } = useTranslation();
|
|
const all = useLogEvents();
|
|
const [filter, setFilter] = useState<Filter>("all");
|
|
const [query, setQuery] = useState("");
|
|
const [open, setOpen] = useState<LogEvent | null>(null);
|
|
|
|
const counts = useMemo(() => {
|
|
const out: Record<Filter, number> = {
|
|
all: all.length,
|
|
error: 0,
|
|
warning: 0,
|
|
info: 0,
|
|
detail: 0,
|
|
};
|
|
for (const e of all) {
|
|
for (const [group, levels] of Object.entries(LEVEL_GROUPS)) {
|
|
if (levels.includes(e.level)) out[group as Filter] += 1;
|
|
}
|
|
}
|
|
return out;
|
|
}, [all]);
|
|
|
|
const shown = useMemo(() => {
|
|
const needle = query.trim().toLowerCase();
|
|
const levels = filter === "all" ? null : LEVEL_GROUPS[filter];
|
|
const out: LogEvent[] = [];
|
|
for (let i = all.length - 1; i >= 0; i--) {
|
|
const e = all[i]!;
|
|
if ((!levels || levels.includes(e.level)) && matches(e, needle)) out.push(e);
|
|
}
|
|
return out;
|
|
}, [all, filter, query]);
|
|
|
|
const copy = async () => {
|
|
// Oldest first, as a log reads — the list shows newest first only for watching it live.
|
|
const text = [...shown]
|
|
.reverse()
|
|
.map((e) => JSON.stringify(e))
|
|
.join("\n");
|
|
const ok = await copyText(text);
|
|
notify(
|
|
ok
|
|
? { level: "success", message: t("settings.myLog.copied", { count: shown.length }) }
|
|
: { level: "error", message: t("settings.myLog.copyFailed") },
|
|
);
|
|
};
|
|
|
|
const filters: readonly Filter[] = ["all", "error", "warning", "info", "detail"];
|
|
|
|
return (
|
|
<div data-testid="my-log" className="overflow-hidden rounded-xl border border-border bg-bg">
|
|
<p className="px-3 pt-2 text-xs text-muted">{t("settings.myLog.hint")}</p>
|
|
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
|
<label className="flex min-w-[10rem] 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={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={t("settings.myLog.search")}
|
|
aria-label={t("settings.myLog.search")}
|
|
className="min-w-0 flex-1 bg-transparent outline-none"
|
|
/>
|
|
</label>
|
|
<FilterChipGroup
|
|
ariaLabel={t("settings.myLog.filterLabel")}
|
|
value={filter}
|
|
onChange={setFilter}
|
|
options={filters.map((f) => ({
|
|
value: f,
|
|
label: t(`settings.myLog.filters.${f}`),
|
|
count: counts[f],
|
|
}))}
|
|
/>
|
|
</div>
|
|
|
|
<div className="max-h-96 overflow-auto font-mono text-2xs" data-testid="my-log-lines">
|
|
{shown.length === 0 ? (
|
|
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
|
|
{all.length === 0 ? t("settings.myLog.emptyAll") : t("settings.myLog.empty")}
|
|
</p>
|
|
) : (
|
|
<NavGrid>
|
|
{shown.slice(0, ROWS_SHOWN).map((e, index) => (
|
|
<LogLine
|
|
navIndex={index}
|
|
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,
|
|
)}
|
|
</pre>
|
|
</LogLine>
|
|
))}
|
|
</NavGrid>
|
|
)}
|
|
</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("settings.myLog.count", { count: all.length })}
|
|
{shown.length > ROWS_SHOWN &&
|
|
` · ${t("settings.myLog.capped", { shown: ROWS_SHOWN, count: shown.length })}`}
|
|
</span>
|
|
<span className="flex gap-1.5">
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
icon={<Copy className="h-3.5 w-3.5" />}
|
|
disabled={shown.length === 0}
|
|
onClick={() => void copy()}
|
|
>
|
|
{t("settings.myLog.copy")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
disabled={all.length === 0}
|
|
onClick={() => {
|
|
clear();
|
|
setOpen(null);
|
|
}}
|
|
>
|
|
{t("settings.myLog.clear")}
|
|
</Button>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|