feat: Settings → Diagnostics, a consented log level and the tab's own log
A user can raise their diagnostics level to Részletes (DEBUG) or Teljes (TRACE) for 24 hours from a new Settings tab, after a consent box that says what is recorded, that the server keeps it and for how long (the instance's own retention), and that it lapses. Alap is the base and needs no consent. - users.diagnostics_level/_until (migration 0087), written only by PUT /api/me/diagnostics, which computes the expiry itself; the demo account cannot raise it. /api/me reports the level in force. - The server logs an elevated user's requests at that level and stores every line of them (logs.LevelGate on the stdout handler and the sink; the root is lowered only while an elevation is active). Teljes adds the redacted query string to the request line. - POST /api/diagnostics/events stores the browser's log as source=web rows: account from the session, body capped while streaming, events/attrs capped, re-redacted, events under the consented level dropped, timestamps clamped, 30 batches a minute, 409 once the level lapsed. - lib/diagnostics.ts applies the level to the tab and streams only what happens after consent, in batches, never its own request lines, with a keepalive flush on pagehide; stops on lapse (its own timer), on withdrawal and on 409. - Detail hooks at DEBUG: which control was clicked (test id, icon, app copy in English — never user text, input values or a link's query), visibility/bfcache/resize, filter changes (lib/filterLog.ts; typed search only at TRACE), player events (lib/mediaLog.ts; media errors are warnings at every level). Offline/online at the base level. - "Saját napló" viewer: live, newest first, level groups with counts, search, row details, copy the filtered events as JSON lines, clear. - Privacy policy and Terms exist in Hungarian and English (a contract test holds both languages to the same sections); the policy now describes the server logs and diagnostic logging, self-service deletion and password sign-in. Fixed on the way: - A sync auth dependency ran logs.set_user on a copied context, so the account never reached a handler's own log lines (measured); a per-request mutable scope carries it now. - The Settings tab rail left a phone no room (clipped labels, buttons over text): below `sm` it is a scrolling row above the content. - PUT /api/me/preferences took a bare dict body; it is a Pydantic model now.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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, subscribe, type Level, type LogEvent } from "../lib/log";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { Button } from "./ui/Button";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
|
||||
type Filter = "all" | "error" | "warning" | "info" | "detail";
|
||||
|
||||
const FILTER_LEVELS: Record<Exclude<Filter, "all">, readonly Level[]> = {
|
||||
error: ["ERROR", "CRITICAL"],
|
||||
warning: ["WARNING"],
|
||||
info: ["INFO"],
|
||||
detail: ["DEBUG", "TRACE"],
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
const LEVEL_TAG: Record<Level, { text: string; className: string }> = {
|
||||
TRACE: { text: "TRACE", className: "text-muted opacity-80" },
|
||||
DEBUG: { text: "DEBUG", className: "text-muted" },
|
||||
INFO: { text: "INFO", className: "text-info" },
|
||||
WARNING: { text: "WARN", className: "text-warn" },
|
||||
ERROR: { text: "ERROR", className: "text-danger" },
|
||||
CRITICAL: { text: "FATAL", className: "text-danger" },
|
||||
};
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
const matches = (event: LogEvent, needle: string) =>
|
||||
!needle ||
|
||||
[event.event, event.msg, event.request_id ?? "", event.attrs ? JSON.stringify(event.attrs) : ""]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(needle);
|
||||
|
||||
const clock = (ts: string) => {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
|
||||
};
|
||||
|
||||
// "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(FILTER_LEVELS)) {
|
||||
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 : FILTER_LEVELS[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>
|
||||
) : (
|
||||
shown.slice(0, ROWS_SHOWN).map((e, i) => {
|
||||
const tag = LEVEL_TAG[e.level];
|
||||
const isOpen = open === e;
|
||||
return (
|
||||
<div key={`${e.ts}-${i}`} className="border-b border-border/50 last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setOpen(isOpen ? null : e)}
|
||||
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-2 px-3 py-1 text-left hover:bg-card ${
|
||||
isOpen ? "bg-accent/10" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="tabular-nums text-muted">{clock(e.ts)}</span>
|
||||
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
|
||||
{/* A phone has no room for a fourth column: the code leads the message there. */}
|
||||
<span className="hidden truncate sm:block">{e.event}</span>
|
||||
<span className="truncate">
|
||||
<span className="sm:hidden">{e.event} · </span>
|
||||
{e.msg}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<pre className="overflow-x-auto whitespace-pre bg-accent/5 px-3 py-2 text-muted">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user