- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch - ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date filter on one trigger, a pill in a toolbar, r8 in a form - ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput - every raw input swept (~100 sites); inputCls retired; local/input-primitive - useReturnFocus: an in-place editor hands the focus back to its trigger on Enter/Escape (the playlist and Plex title renames, the priority number); the Plex playlist rename gains Escape and an accessible name - back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log
226 lines
7.8 KiB
TypeScript
226 lines
7.8 KiB
TypeScript
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<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={clsx(
|
|
fieldClass({ size: "sm", pill: true }),
|
|
"flex min-w-[10rem] flex-1 items-center gap-1.5",
|
|
)}
|
|
>
|
|
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
|
|
<InnerInput
|
|
type="search"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={t("settings.myLog.search")}
|
|
aria-label={t("settings.myLog.search")}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|