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:
@@ -1,5 +1,7 @@
|
||||
// The client log's app-wide hooks (E22 S4), installed once from main.tsx before anything renders:
|
||||
// the start snapshot, uncaught errors and rejections, and navigation.
|
||||
// the start snapshot, uncaught errors and rejections, navigation, connectivity, and — only while the
|
||||
// user's consented level is raised (E22 S5) — which control was used and what the tab itself went
|
||||
// through (hidden/shown, restored from the back-forward cache, resized).
|
||||
//
|
||||
// Navigation has no single choke point in this router-free app — `setPage`, the channel and search
|
||||
// overlays, sub-views, the overlay stack and the URL mirrors each call `history.pushState` /
|
||||
@@ -61,10 +63,112 @@ function recordView(via: Via): void {
|
||||
log.info("nav.view", `${via} ${String(view.page ?? view.path)}`, { via, ...view });
|
||||
}
|
||||
|
||||
// What counts as a control for click capture: anything a user operates.
|
||||
const CONTROL = [
|
||||
"button",
|
||||
"a[href]",
|
||||
"input",
|
||||
"select",
|
||||
"textarea",
|
||||
"summary",
|
||||
...[
|
||||
"button",
|
||||
"link",
|
||||
"tab",
|
||||
"menuitem",
|
||||
"menuitemradio",
|
||||
"menuitemcheckbox",
|
||||
"option",
|
||||
"switch",
|
||||
"checkbox",
|
||||
"radio",
|
||||
].map((role) => `[role="${role}"]`),
|
||||
].join(", ");
|
||||
const ARIA_STATE = ["aria-pressed", "aria-checked", "aria-expanded", "aria-selected"] as const;
|
||||
|
||||
type AppCopy = (text: string | null) => string | null;
|
||||
|
||||
/** Which control this is — never what it says, unless what it says is the app's own copy.
|
||||
* Recorded: its test id (its own, else the nearest enclosing one as `within`), its lucide icon, the
|
||||
* English wording of its aria-label/text/title when that is exactly app copy (`appCopy`), its tag,
|
||||
* role and input type, its aria state, whether a checkbox is checked, and for a link the path
|
||||
* TEMPLATE or just "external". Never: an input's value, free label text, an href's query. */
|
||||
function controlAttrs(el: Element, appCopy: AppCopy): Record<string, unknown> {
|
||||
const attrs: Record<string, unknown> = { tag: el.tagName.toLowerCase() };
|
||||
const role = el.getAttribute("role");
|
||||
if (role) attrs.role = role;
|
||||
const testId = el.getAttribute("data-testid");
|
||||
if (testId) attrs.control = testId;
|
||||
else {
|
||||
const within = el.parentElement?.closest("[data-testid]")?.getAttribute("data-testid");
|
||||
if (within) attrs.within = within;
|
||||
}
|
||||
const icon = [...(el.querySelector("svg.lucide")?.classList ?? [])].find(
|
||||
(c) => c.startsWith("lucide-") && c !== "lucide-icon",
|
||||
);
|
||||
if (icon) attrs.icon = icon.slice("lucide-".length);
|
||||
const label =
|
||||
appCopy(el.getAttribute("aria-label")) ??
|
||||
appCopy(el.textContent) ??
|
||||
appCopy(el.getAttribute("title"));
|
||||
if (label) attrs.label = label;
|
||||
if (el instanceof HTMLInputElement) {
|
||||
attrs.type = el.type;
|
||||
if (el.type === "checkbox" || el.type === "radio") attrs.checked = el.checked;
|
||||
}
|
||||
for (const name of ARIA_STATE) {
|
||||
const value = el.getAttribute(name);
|
||||
if (value !== null) attrs[name.slice("aria-".length)] = value;
|
||||
}
|
||||
if (el instanceof HTMLAnchorElement) {
|
||||
attrs.href = el.origin === window.location.origin ? pathTemplate(el.pathname) : "external";
|
||||
}
|
||||
const page = (window.history.state as Record<string, unknown> | null)?.sfPage;
|
||||
if (typeof page === "string") attrs.page = page;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function installDetailHooks(appCopy: AppCopy): void {
|
||||
// Capture phase, so a handler that stops propagation cannot hide the click from the log.
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (!enabled("DEBUG") || !(e.target instanceof Element)) return;
|
||||
const control = e.target.closest(CONTROL);
|
||||
if (!control) return;
|
||||
const attrs = controlAttrs(control, appCopy);
|
||||
const name = attrs.control ?? attrs.label ?? attrs.icon ?? attrs.within ?? attrs.tag;
|
||||
log.debug("ui.click", `click ${String(name)}`, attrs);
|
||||
},
|
||||
true,
|
||||
);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
log.debug("app.visibility", document.visibilityState, { state: document.visibilityState });
|
||||
});
|
||||
window.addEventListener("pageshow", (e) => {
|
||||
if (e.persisted) log.debug("app.restored", "restored from the back-forward cache");
|
||||
});
|
||||
let resizeTimer: number | null = null;
|
||||
window.addEventListener("resize", () => {
|
||||
if (!enabled("DEBUG")) return;
|
||||
if (resizeTimer !== null) window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(() => {
|
||||
resizeTimer = null;
|
||||
const viewport = `${window.innerWidth}x${window.innerHeight}`;
|
||||
log.debug("app.resize", viewport, { viewport });
|
||||
}, 500);
|
||||
});
|
||||
// Connectivity is worth a line at the base level: it explains a burst of failed requests.
|
||||
window.addEventListener("offline", () => log.warn("app.network", "offline", { online: false }));
|
||||
window.addEventListener("online", () => log.info("app.network", "online", { online: true }));
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
/** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice. */
|
||||
export function installClientLog(): void {
|
||||
/** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice.
|
||||
* `appCopy` turns on-screen app copy into its English wording (i18n/appCopy.ts) — passed in, so this
|
||||
* module stays free of the i18n runtime. */
|
||||
export function installClientLog(appCopy: AppCopy = () => null): void {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
|
||||
@@ -103,4 +207,5 @@ export function installClientLog(): void {
|
||||
}
|
||||
window.addEventListener("popstate", () => recordView("pop"));
|
||||
recordView("start");
|
||||
installDetailHooks(appCopy);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user