Admin → Diagnostics reads the server's stored log: one list, newest first, where a line opens in place to its full record (time, source and logger, account, request and job ids, version, message, attributes, stack). A request or job id narrows the list to that request or job with every other filter off, shown as a removable chip. Filters: level group, source, account, event, period and a free-text search; all of them live in the URL (`l*` keys, which setPage clears like the Plex and Channels keys), so a filtered view is a link and Back restores it. "Live" re-reads the newest page every few seconds while the tab is visible; paging into older rows turns it off. Copy puts the loaded lines on the clipboard as JSON lines. The facet counts now honour every filter but their own, as the feed's do: a request pivot narrows them, and a picked level does not zero its siblings. A value in use stays offered even when the counts drop it. "Saját napló" and the console draw a line with one shared component, `LogLine`, whose phone layout gives the message its own full-width line (sharing the code's line left it a few characters). The level groups moved to lib/log.ts for both. The console's own reads are logged at DEBUG on success, like the healthcheck: at INFO its live tail filled the log with itself.
245 lines
9.2 KiB
TypeScript
245 lines
9.2 KiB
TypeScript
// The client log (E22 S4): the browser half of the ONE event shape `backend/app/logs.py` defines.
|
|
//
|
|
// Every event is `{ts, level, sev, source: "web", event, msg, attrs, request_id?, tab_id, version}` —
|
|
// the same fields and level names as a server line, so a client batch can land in `log_events` beside
|
|
// the server's own rows and a request joins across the two by `request_id`.
|
|
//
|
|
// Events are kept in memory only, in a bounded ring: nothing leaves the browser from here. Sending
|
|
// them is `lib/diagnostics.ts`'s job, and only while the user's consented level is raised (E22 S5);
|
|
// it and the Settings log viewer follow the ring through `subscribe`.
|
|
//
|
|
// REDACTION HAPPENS HERE, at capture, not at the call site — the same rules as the server's handler,
|
|
// held to them by a fixture both test suites run (`backend/tests/fixtures/log_redaction_cases.json`).
|
|
// A secret cannot leak by forgetting to scrub it at one call site, and a new call site is safe by
|
|
// default. Never kept, at any level: passwords, tokens, cookies, secret config, E2EE material, email
|
|
// addresses. What a level ADDS (filter values at DEBUG, search strings at TRACE) is decided by the
|
|
// call site asking `enabled(level)` before it builds the attribute.
|
|
//
|
|
// Dependency-free on purpose (only the build stamp): the request layer, the notification store and
|
|
// the error boundary all log through here, so it must not import any of them.
|
|
import { FRONTEND_VERSION } from "./version";
|
|
|
|
export type Level = "TRACE" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
|
|
|
|
/** OpenTelemetry severity numbers — the same table as `logs.SEVERITY` (a contract test compares). */
|
|
export const SEVERITY: Record<Level, number> = {
|
|
TRACE: 1,
|
|
DEBUG: 5,
|
|
INFO: 9,
|
|
WARNING: 13,
|
|
ERROR: 17,
|
|
CRITICAL: 21,
|
|
};
|
|
|
|
/** How a log viewer groups the six levels into its filter chips — "Saját napló" and the admin
|
|
* console both read a log through these, so a level lands in the same chip on either screen. */
|
|
export type LevelGroup = "all" | "error" | "warning" | "info" | "detail";
|
|
|
|
export const LEVEL_GROUPS: Record<Exclude<LevelGroup, "all">, readonly Level[]> = {
|
|
error: ["ERROR", "CRITICAL"],
|
|
warning: ["WARNING"],
|
|
info: ["INFO"],
|
|
detail: ["DEBUG", "TRACE"],
|
|
};
|
|
|
|
export interface LogEvent {
|
|
ts: string;
|
|
level: Level;
|
|
sev: number;
|
|
source: "web";
|
|
event: string;
|
|
msg: string;
|
|
attrs?: Record<string, unknown>;
|
|
request_id?: string;
|
|
tab_id: string;
|
|
version: string;
|
|
}
|
|
|
|
/** Events kept. At ~0.5 KB each the ring stays around a megabyte — a long session's worth of
|
|
* navigation and requests, without growing for as long as a tab stays open. */
|
|
export const RING_MAX = 2000;
|
|
|
|
// --- redaction (mirrors backend/app/logs.py) ------------------------------------------------------
|
|
// Substring matching on the KEY: `password`, `current_password`, `plex_server_token`, … — a key we
|
|
// have never seen is safe by default.
|
|
const SECRET_KEY_PARTS = [
|
|
"password",
|
|
"passphrase",
|
|
"token",
|
|
"secret",
|
|
"cookie",
|
|
"authorization",
|
|
"auth_header",
|
|
"ciphertext",
|
|
"private_key",
|
|
"api_key",
|
|
"apikey",
|
|
"credential",
|
|
"session_key",
|
|
];
|
|
|
|
// Credential-bearing URL parameters (OAuth `code`/`state`, `?token=` links, `#verify=`/`#reset=`).
|
|
const SECRET_PARAM_RE =
|
|
/\b((?:access_token|refresh_token|id_token|token|code|state|key|secret|password|pwd|sig|signature|verify|reset|setup)=)([^&\s"']+)/gi;
|
|
|
|
// Deliberately loose, as on the server: over-matching is harmless, under-matching is not.
|
|
const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
|
|
const REDACTED = "[redacted]";
|
|
const REDACTED_EMAIL = "[email]";
|
|
|
|
/** Scrub free text: credential-bearing URL params and email addresses. */
|
|
function redactText(text: string): string {
|
|
return text.replace(SECRET_PARAM_RE, `$1${REDACTED}`).replace(EMAIL_RE, REDACTED_EMAIL);
|
|
}
|
|
|
|
/** Recursively scrub a structure destined for the log. A value whose KEY names a secret is dropped
|
|
* whole — a prefix of a token is still a leak. Everything else is scrubbed as text. */
|
|
export function redact(value: unknown, key = ""): unknown {
|
|
const lowered = key.toLowerCase();
|
|
if (key && SECRET_KEY_PARTS.some((part) => lowered.includes(part))) return REDACTED;
|
|
if (Array.isArray(value)) return value.map((v) => redact(v, key));
|
|
if (typeof value === "string") return redactText(value);
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redact(v, k)]));
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// --- identity -------------------------------------------------------------------------------------
|
|
function randomHex(bytes: number): string {
|
|
const buf = new Uint8Array(bytes);
|
|
crypto.getRandomValues(buf);
|
|
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
/** An id for one outgoing request, sent as `X-Request-Id`. The server accepts it (it matches
|
|
* `logs._REQUEST_ID_RE`) and stamps its own line with it, which is what joins the two logs. */
|
|
export function newRequestId(): string {
|
|
return randomHex(8);
|
|
}
|
|
|
|
// Per TAB, not per page load: a reload loop is one tab's story, and sessionStorage is exactly that
|
|
// scope. Storage that throws (a locked-down browser) just means a fresh id per load.
|
|
const TAB_KEY = "siftlode.logTab";
|
|
function tabId(): string {
|
|
try {
|
|
const known = sessionStorage.getItem(TAB_KEY);
|
|
if (known) return known;
|
|
const id = randomHex(6);
|
|
sessionStorage.setItem(TAB_KEY, id);
|
|
return id;
|
|
} catch {
|
|
return randomHex(6);
|
|
}
|
|
}
|
|
const TAB_ID = tabId();
|
|
|
|
// --- the logger -----------------------------------------------------------------------------------
|
|
let threshold: Level = "INFO";
|
|
let ring: LogEvent[] = [];
|
|
|
|
/** The capture level. INFO is the always-on "Alap"; DEBUG and TRACE come from the user's consented
|
|
* diagnostics level (E22 S5). */
|
|
export function setLevel(level: Level): void {
|
|
threshold = level;
|
|
}
|
|
|
|
/** Would an event at `level` be kept? Ask before building an attribute that costs something or that
|
|
* only a higher level may carry (a search string is TRACE-only). */
|
|
export function enabled(level: Level): boolean {
|
|
return SEVERITY[level] >= SEVERITY[threshold];
|
|
}
|
|
|
|
/** The kept events, oldest first (a copy). */
|
|
export function events(): LogEvent[] {
|
|
return ring.slice();
|
|
}
|
|
|
|
/** Drop every kept event (the viewer's "clear", tests). */
|
|
export function clear(): void {
|
|
ring = [];
|
|
notify(null);
|
|
}
|
|
|
|
// Told about every kept event as it is kept (`null` = the ring was cleared). A listener that throws
|
|
// is its own problem: it must not stop the others, nor the code that logged.
|
|
type Listener = (event: LogEvent | null) => void;
|
|
const listeners = new Set<Listener>();
|
|
|
|
/** Follow the ring: called with each event once it is kept, and with `null` when it is cleared.
|
|
* Returns the unsubscribe. */
|
|
export function subscribe(listener: Listener): () => void {
|
|
listeners.add(listener);
|
|
return () => {
|
|
listeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
function notify(event: LogEvent | null): void {
|
|
for (const listener of listeners) {
|
|
try {
|
|
listener(event);
|
|
} catch {
|
|
// see Listener
|
|
}
|
|
}
|
|
}
|
|
|
|
function emit(
|
|
level: Level,
|
|
event: string,
|
|
msg: string,
|
|
attrs?: Record<string, unknown>,
|
|
requestId?: string,
|
|
): void {
|
|
if (!enabled(level)) return;
|
|
try {
|
|
const entry: LogEvent = {
|
|
ts: new Date().toISOString(),
|
|
level,
|
|
sev: SEVERITY[level],
|
|
source: "web",
|
|
event,
|
|
msg: redactText(msg),
|
|
tab_id: TAB_ID,
|
|
version: FRONTEND_VERSION,
|
|
};
|
|
if (attrs && Object.keys(attrs).length) entry.attrs = redact(attrs) as Record<string, unknown>;
|
|
if (requestId) entry.request_id = requestId;
|
|
ring.push(entry);
|
|
if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX);
|
|
notify(entry);
|
|
} catch {
|
|
// Logging must never break the code that logs (an attr with a throwing getter, a cycle).
|
|
}
|
|
}
|
|
|
|
type Emit = (event: string, msg: string, attrs?: Record<string, unknown>) => void;
|
|
|
|
/** `log.info("nav.view", "page opened", {page})` — a stable dotted event code, a short English
|
|
* message for developers, structured attributes. */
|
|
export const log: Record<"trace" | "debug" | "info" | "warn" | "error" | "fatal", Emit> & {
|
|
at: (level: Level, event: string, msg: string, attrs?: Record<string, unknown>) => void;
|
|
request: (level: Level, msg: string, attrs: Record<string, unknown>, requestId: string) => void;
|
|
} = {
|
|
trace: (e, m, a) => emit("TRACE", e, m, a),
|
|
debug: (e, m, a) => emit("DEBUG", e, m, a),
|
|
info: (e, m, a) => emit("INFO", e, m, a),
|
|
warn: (e, m, a) => emit("WARNING", e, m, a),
|
|
error: (e, m, a) => emit("ERROR", e, m, a),
|
|
fatal: (e, m, a) => emit("CRITICAL", e, m, a),
|
|
/** At a level decided at run time (a notification's, a status code's). */
|
|
at: (level, e, m, a) => emit(level, e, m, a),
|
|
/** One `http.request` event, carrying the request id as the correlation field. */
|
|
request: (level, msg, attrs, requestId) => emit(level, "http.request", msg, attrs, requestId),
|
|
};
|
|
|
|
/** An error as loggable attributes: its kind, message and stack — redacted like everything else. */
|
|
export function errorAttrs(err: unknown): Record<string, unknown> {
|
|
if (err instanceof Error) {
|
|
return { error: err.name, message: err.message, ...(err.stack ? { stack: err.stack } : {}) };
|
|
}
|
|
return { error: typeof err, message: String(err) };
|
|
}
|