Files
siftlode/frontend/src/components/MyLogViewer.tsx
T
peter c5e022f056 refactor(frontend): the long tail — and eslint-suppressions.json is gone (E27 S2)
Closes the burn-down. Hook suppressions 113 -> 34, `exhaustive-deps` 45 -> 2 (the target was
<10), and every one of the 34 survivors is an inline directive NAMING the invariant it
protects. The suppressions file is deleted, because a bulk file hides the next violation
instead of answering it — and it cannot hold a warning-level rule at all.

The sprint plan's three verdicts missed the answer that turned out to be the most common one:
adjust the state DURING RENDER. React re-runs the component and discards that pass rather than
committing it, so the stale frame an effect-based reset paints does not exist. Applied to the
Plex player's up-next card, the playlist view reset, the feed's optimistic overrides, the
channel banner's retry count, the download center's tab guard, the Plex search highlight, the
comfort-pin latch, Welcome's per-view outcome and the card pager's page clamp. It is legal only
for a component's own state — PlexBrowse's language commit resets PlexProvider's filters, so
that one stays an effect and says why.

Mirrors of a prop or query became derived values or `draft ?? server`:
- `downloadRow` had ui/Avatar's defect exactly — a boolean "poster failed" that an effect reset,
  in rows recycled down a virtualized list, so one job's missing poster painted over the next
  one's for a frame. Keyed by URL now.
- Scheduler's two number editors held a mirror an effect re-synced, on a page that POLLS — the
  shape a poll can overwrite mid-edit. A draft while editing, the server's value otherwise; six
  tests where there were none, the one behavioural change (the save button stops being dirty at
  commit rather than at the echo) mutation-checked.
- SetupWizard's "no token" and WatchPage's "gone" are facts about the URL, so they are seeded and
  derived rather than pushed into state by an effect that had to commit first.

Elsewhere: PlexInfo's rating chip was a component declared during render — a new component type
every render, so React remounted the subtree and dropped the focus on its filter button. Hoisted.
Feed's undo action and PlexPlayer's session reclaim both re-entered their own useCallback, which
the checker reads as a forward reference; both go through the ref idiom this file already uses.
BootGate's dep list was load-bearing for correctness — a re-run meant `location.reload()` — so
the "already adopted THIS identity" comparison now enforces it in code, with a test that fails
without it (the existing reload tests all install their spy too late to catch it).

One correction to the batch before this: keying ChatThread's and Messages' decrypt passes on
`q.data` instead of `q.dataUpdatedAt` was wrong. react-query's structural sharing makes it safe
in production, but the component must not melt down when something hands back a fresh object per
render — and Messages.keyboard.dom.test does exactly that, so the effect span the suite. Both are
back on the fetch as the trigger with the payload behind a ref, which is honest AND loop-proof;
the replaced comment had warned about precisely this.

CLAUDE.md gains four canonical-patterns rows for the above, and its "React 18" line is now true.
2026-09-21 01:44:21 +02:00

219 lines
7.8 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());
});
});
// 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="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>
);
}