Files
siftlode/frontend/src/components/MyLogViewer.tsx
T
peter dac9d89f91 feat(design): the field family, the dropdown trigger, native controls, the no-raw-input lint rule (E28 S3c)
- 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
2026-09-24 23:48:22 +02:00

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>
);
}