import clsx from "clsx"; 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"; import { InnerInput } from "./ui/Input"; import { fieldClass } from "./ui/fieldStyles"; // 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()); }); }); // eslint-disable-next-line react-hooks/set-state-in-effect -- this IS the subscribe-to-an-external-store shape the rule's own text allows: the ring fills outside React, and a sync at subscribe time is what closes the gap between the first render and the listener. useSyncExternalStore would drop the rAF coalescing, which is the point at a few hundred events a second. 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(); 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("all"); const [query, setQuery] = useState(""); const [open, setOpen] = useState(null); const counts = useMemo(() => { const out: Record = { 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 (

{t("settings.myLog.hint")}

({ value: f, label: t(`settings.myLog.filters.${f}`), count: counts[f], }))} />
{shown.length === 0 ? (

{all.length === 0 ? t("settings.myLog.emptyAll") : t("settings.myLog.empty")}

) : ( {shown.slice(0, ROWS_SHOWN).map((e, index) => ( setOpen(open === e ? null : e)} >
                  {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,
                  )}
                
))}
)}
{t("settings.myLog.count", { count: all.length })} {shown.length > ROWS_SHOWN && ` · ${t("settings.myLog.capped", { shown: ROWS_SHOWN, count: shown.length })}`}
); }