feat(logs): app log store — backend pino DB sink + frontend error collection
Add a third data stream (app_logs), distinct from the signed ledger and device telemetry, for operational/diagnostic logs — an offline appliance has no Sentry to ship to, so the host is the log store. Backend: a pino stream tees warn/error/fatal into app_logs (info/debug stay stdout-only) with no call-site change; the DB is built before Fastify so the logger has its sink. Frontend (lib/logger.ts): ships failed API requests (minus 401 churn), window.onerror, unhandledrejection, and a top-level React ErrorBoundary; console warn/error forwarded only at debug/trace. Batched/throttled POST, sendBeacon on pagehide, loop-safe (never logs the /api/logs call), best-effort everywhere. POST /api/logs (any signed-in user, CSRF, tolerant) + GET /api/logs gated by a new log:read permission (new `log` RBAC resource; admin holds it). Retention: pruned by age + row cap, hourly + at startup. UI: a Logs screen under /setup (filter level/source/since, expand to context+stack), sq+en. Migration 0009_app_logs. Verified end-to-end via app.inject: login -> POST 204 -> GET 200 with the record; backend warn/error persisted, info dropped; non-admin GET 403 / POST 204. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchLogs, type AppLogRecord, type LogLevel } from "./api.js";
|
||||
import { formatRelativeDateTime } from "./lib/format.js";
|
||||
|
||||
// Diagnostic log viewer (app_logs) — backend warn+ and frontend errors in one place.
|
||||
// Gated by log:read server-side. Filter by level / source / since; each row expands to
|
||||
// the structured context + stack. Read-only — logs are an evidence/diagnostic stream,
|
||||
// never edited. See wiki/concepts/app-logs.md.
|
||||
|
||||
const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
||||
|
||||
/** Terminal-theme colour per level. */
|
||||
const LEVEL_COLOR: Record<LogLevel, string> = {
|
||||
trace: "text-term-muted",
|
||||
debug: "text-term-muted",
|
||||
info: "text-term-cyan",
|
||||
warn: "text-term-amber",
|
||||
error: "text-term-red",
|
||||
fatal: "text-term-red",
|
||||
};
|
||||
|
||||
function LogRow({ log }: { log: AppLogRecord }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasDetail = (log.context && Object.keys(log.context).length > 0) || log.stack;
|
||||
|
||||
return (
|
||||
<div className={`border-b border-term-border/50 ${log.level === "error" || log.level === "fatal" ? "bg-term-red/5" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasDetail && setOpen((v) => !v)}
|
||||
className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[12px] ${
|
||||
hasDetail ? "hover:bg-term-panel-2" : "cursor-default"
|
||||
}`}
|
||||
>
|
||||
<span className="text-term-muted tabular-nums">{formatRelativeDateTime(log.createdAt, t)}</span>
|
||||
<span className={`font-semibold uppercase ${LEVEL_COLOR[log.level]}`}>{log.level}</span>
|
||||
<span className="text-term-muted">{t(log.source === "frontend" ? "logs.frontend" : "logs.backend")}</span>
|
||||
<span className="truncate text-term-text">{log.message}</span>
|
||||
<span className="text-term-muted tabular-nums">{log.httpStatus ?? ""}</span>
|
||||
</button>
|
||||
{open && hasDetail && (
|
||||
<div className="border-t border-term-border/40 bg-term-bg px-3 py-2">
|
||||
{log.path && (
|
||||
<div className="mb-1 text-[11px] text-term-muted">
|
||||
{t("logs.path")}: <code className="text-term-text">{log.path}</code>
|
||||
</div>
|
||||
)}
|
||||
{log.context && Object.keys(log.context).length > 0 && (
|
||||
<pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-text">
|
||||
{JSON.stringify(log.context, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{log.stack && (
|
||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-red/90">
|
||||
{log.stack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsViewer() {
|
||||
const { t } = useTranslation();
|
||||
const [level, setLevel] = useState("");
|
||||
const [source, setSource] = useState("");
|
||||
const [since, setSince] = useState("");
|
||||
const [applied, setApplied] = useState<{ level?: string; source?: string; since?: string }>({});
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ["logs", applied],
|
||||
queryFn: () => fetchLogs({ ...applied, limit: 500 }),
|
||||
refetchInterval: 15_000, // keep the booth view roughly live without a WS
|
||||
});
|
||||
|
||||
const logs = q.data?.logs ?? [];
|
||||
|
||||
function apply() {
|
||||
setApplied({
|
||||
level: level || undefined,
|
||||
source: source || undefined,
|
||||
since: since ? new Date(`${since}T00:00:00`).toISOString() : undefined,
|
||||
});
|
||||
}
|
||||
function clear() {
|
||||
setLevel("");
|
||||
setSource("");
|
||||
setSince("");
|
||||
setApplied({});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("logs.title")}</h1>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => q.refetch()}>
|
||||
{t("logs.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card mb-3 flex flex-wrap items-end gap-3 p-3">
|
||||
<div className="field">
|
||||
<span className="label">{t("logs.level")}</span>
|
||||
<select className="select w-32" value={level} onChange={(e) => setLevel(e.target.value)}>
|
||||
<option value="">{t("logs.allLevels")}</option>
|
||||
{LEVELS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("logs.source")}</span>
|
||||
<select className="select w-36" value={source} onChange={(e) => setSource(e.target.value)}>
|
||||
<option value="">{t("logs.allSources")}</option>
|
||||
<option value="frontend">{t("logs.frontend")}</option>
|
||||
<option value="backend">{t("logs.backend")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("logs.since")}</span>
|
||||
<input type="date" className="input w-40" value={since} onChange={(e) => setSince(e.target.value)} />
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={apply}>
|
||||
{t("logs.apply")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={clear}>
|
||||
{t("logs.clear")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-2">
|
||||
{q.isLoading ? (
|
||||
<div className="p-3 text-[12px] text-term-muted">{t("common.loading")}</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="p-3 text-[12px] text-term-muted">{t("logs.empty")}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[10px] uppercase tracking-wider text-term-muted">
|
||||
<span>{t("logs.time")}</span>
|
||||
<span>{t("logs.level")}</span>
|
||||
<span>{t("logs.source")}</span>
|
||||
<span>{t("logs.message")}</span>
|
||||
<span>{t("logs.status")}</span>
|
||||
</div>
|
||||
{logs.map((log) => (
|
||||
<LogRow key={log.id} log={log} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
-2
@@ -5,6 +5,9 @@
|
||||
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
|
||||
// wiki/entities/local-jwt-auth.md.
|
||||
|
||||
import { logFailedRequest } from "./lib/logger.js";
|
||||
import type { AppLogRecord } from "@parking/shared";
|
||||
|
||||
const CSRF_COOKIE = "parking_csrf";
|
||||
const CSRF_HEADER = "X-CSRF-Token";
|
||||
|
||||
@@ -27,7 +30,14 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
const res = await fetch(path, { ...init, headers, credentials: "include" });
|
||||
if (!res.ok) {
|
||||
const msg = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new ApiError(msg.error ?? `${path}: ${res.status}`, res.status);
|
||||
const error = msg.error ?? `${path}: ${res.status}`;
|
||||
// Ship the failed request to the backend log store (best-effort, loop-safe — the
|
||||
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
|
||||
// so we don't report them as errors. See lib/logger.ts.
|
||||
if (res.status !== 401) {
|
||||
logFailedRequest({ path, method, status: res.status, error });
|
||||
}
|
||||
throw new ApiError(error, res.status);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
@@ -160,6 +170,23 @@ export function deleteRole(id: string): Promise<{ ok: boolean }> {
|
||||
return apiFetch(`/api/roles/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// --- Application logs (app_logs) ------------------------------------------
|
||||
/** Read recent diagnostic logs (gated server-side by log:read). */
|
||||
export function fetchLogs(params: {
|
||||
limit?: number;
|
||||
level?: string;
|
||||
source?: string;
|
||||
since?: string;
|
||||
} = {}): Promise<{ logs: AppLogRecord[] }> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.limit) q.set("limit", String(params.limit));
|
||||
if (params.level) q.set("level", params.level);
|
||||
if (params.source) q.set("source", params.source);
|
||||
if (params.since) q.set("since", params.since);
|
||||
const qs = q.toString();
|
||||
return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
// --- Device setup ---------------------------------------------------------
|
||||
|
||||
export interface ConfigField {
|
||||
@@ -632,7 +659,8 @@ export function fetchDeviceStatus(): Promise<{ devices: DeviceStatus[] }> {
|
||||
|
||||
/** A persisted ledger row. Re-exported from shared so UI code has one source of
|
||||
* truth for the event shape (the same type the WS pushes). */
|
||||
export type { LedgerEvent } from "@parking/shared";
|
||||
export type { LedgerEvent, LogLevel, LogSource } from "@parking/shared";
|
||||
export type { AppLogRecord };
|
||||
|
||||
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
||||
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { logClient } from "./logger.js";
|
||||
|
||||
// Top-level React error boundary: catches a render/lifecycle crash anywhere in the
|
||||
// tree, reports it to the backend log store (app_logs), and shows a minimal recovery
|
||||
// screen instead of a white page. A booth must never be left staring at a blank
|
||||
// screen with no trace of why. See wiki/concepts/app-logs.md.
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
|
||||
override state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(err: Error): State {
|
||||
return { hasError: true, message: err.message };
|
||||
}
|
||||
|
||||
override componentDidCatch(err: Error, info: ErrorInfo): void {
|
||||
logClient({
|
||||
level: "fatal",
|
||||
message: err.message || "React render error",
|
||||
stack: err.stack,
|
||||
path: typeof location !== "undefined" ? location.pathname : undefined,
|
||||
context: { kind: "react_error_boundary", componentStack: info.componentStack },
|
||||
});
|
||||
}
|
||||
|
||||
override render(): ReactNode {
|
||||
if (!this.state.hasError) return this.props.children;
|
||||
// Intentionally un-i18n'd + dependency-free: the app tree just crashed, so we can't
|
||||
// assume providers (i18n/router/query) are healthy.
|
||||
return (
|
||||
<div style={{ padding: "2rem", fontFamily: "monospace", color: "#e5e5e5", background: "#0a0a0a", minHeight: "100vh" }}>
|
||||
<h1 style={{ color: "#ef4444" }}>Something went wrong</h1>
|
||||
<p>The screen crashed and has been reported. Try reloading.</p>
|
||||
{this.state.message && <pre style={{ color: "#a3a3a3" }}>{this.state.message}</pre>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => location.reload()}
|
||||
style={{ marginTop: "1rem", padding: "0.5rem 1rem", cursor: "pointer" }}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export const en: Catalog = {
|
||||
users: "Users",
|
||||
roles: "Roles",
|
||||
shifts: "Shifts",
|
||||
logs: "Logs",
|
||||
},
|
||||
status: {
|
||||
live: "LIVE",
|
||||
@@ -482,6 +483,24 @@ export const en: Catalog = {
|
||||
cashRemoved: "Cash removed",
|
||||
loadFailed: "Failed to load shifts.",
|
||||
},
|
||||
logs: {
|
||||
title: "System logs",
|
||||
refresh: "Refresh",
|
||||
level: "Level",
|
||||
source: "Source",
|
||||
since: "Since",
|
||||
apply: "Apply",
|
||||
clear: "Clear",
|
||||
allLevels: "All levels",
|
||||
allSources: "All sources",
|
||||
frontend: "Frontend",
|
||||
backend: "Backend",
|
||||
time: "Time",
|
||||
message: "Message",
|
||||
status: "Status",
|
||||
path: "Path",
|
||||
empty: "No logs.",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Ticket",
|
||||
entry: "Entry",
|
||||
|
||||
@@ -51,6 +51,7 @@ export const sq = {
|
||||
users: "Përdoruesit",
|
||||
roles: "Rolet",
|
||||
shifts: "Turnet",
|
||||
logs: "Regjistrat",
|
||||
},
|
||||
status: {
|
||||
live: "LIVE",
|
||||
@@ -495,6 +496,24 @@ export const sq = {
|
||||
cashRemoved: "Para të hequra",
|
||||
loadFailed: "Ngarkimi i turneve dështoi.",
|
||||
},
|
||||
logs: {
|
||||
title: "Regjistrat e sistemit",
|
||||
refresh: "Rifresko",
|
||||
level: "Niveli",
|
||||
source: "Burimi",
|
||||
since: "Që nga",
|
||||
apply: "Apliko",
|
||||
clear: "Pastro",
|
||||
allLevels: "Të gjitha nivelet",
|
||||
allSources: "Të gjitha burimet",
|
||||
frontend: "Ndërfaqja",
|
||||
backend: "Serveri",
|
||||
time: "Koha",
|
||||
message: "Mesazhi",
|
||||
status: "Statusi",
|
||||
path: "Rruga",
|
||||
empty: "Asnjë regjistër.",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Bileta",
|
||||
entry: "Hyrja",
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// Frontend error/log collector. Ships failed requests, uncaught errors, and rejected
|
||||
// promises to the backend (POST /api/logs → app_logs), so a booth problem is
|
||||
// diagnosable from the host instead of needing the operator's devtools. See
|
||||
// wiki/concepts/app-logs.md.
|
||||
//
|
||||
// Design notes:
|
||||
// - BATCHED + THROTTLED: entries queue and flush on a short timer (and on page hide
|
||||
// via sendBeacon), so a burst of errors is one request, not hundreds.
|
||||
// - LOOP-SAFE: a failure of the /api/logs request itself is NEVER re-logged (that
|
||||
// would be an infinite error → log → error spiral). We also never recurse through
|
||||
// apiFetch — the flush uses raw fetch/sendBeacon.
|
||||
// - LEVEL-GATED noise: console.warn/error are only forwarded when the client log
|
||||
// level is debug/trace (off by default) — they're noisy (3rd-party chatter). The
|
||||
// high-signal sources (failed requests, uncaught errors) are always captured.
|
||||
|
||||
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
|
||||
|
||||
const ENDPOINT = "/api/logs";
|
||||
const FLUSH_MS = 4000;
|
||||
const MAX_QUEUE = 100; // drop oldest beyond this (bounded memory on a long-lived booth)
|
||||
const CSRF_COOKIE = "parking_csrf";
|
||||
const CSRF_HEADER = "X-CSRF-Token";
|
||||
|
||||
/** The client capture threshold. Entries below this level are dropped before queueing.
|
||||
* Default `info`: failed requests (error) + uncaught errors (error) always pass;
|
||||
* console.warn/error forwarding is wired separately and only ON at debug/trace. */
|
||||
let clientLevel: LogLevel = (import.meta.env.VITE_LOG_LEVEL as LogLevel) || "info";
|
||||
|
||||
export function setClientLogLevel(level: LogLevel): void {
|
||||
clientLevel = level;
|
||||
}
|
||||
export function getClientLogLevel(): LogLevel {
|
||||
return clientLevel;
|
||||
}
|
||||
/** Are console.warn/error forwarded? Only when the client level is debug or trace. */
|
||||
function consoleForwardEnabled(): boolean {
|
||||
return LOG_LEVEL_ORDER[clientLevel] <= LOG_LEVEL_ORDER.debug;
|
||||
}
|
||||
|
||||
const queue: ClientLogInput[] = [];
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Set true only while flushing, so the flush's own network activity is never logged. */
|
||||
let flushing = false;
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||
return m ? decodeURIComponent(m[1]!) : null;
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (timer != null) return;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
void flush();
|
||||
}, FLUSH_MS);
|
||||
}
|
||||
|
||||
/** Enqueue an entry. Drops it if below the client level or if it concerns the log
|
||||
* endpoint itself (loop guard). */
|
||||
export function logClient(entry: ClientLogInput): void {
|
||||
if (LOG_LEVEL_ORDER[entry.level] < LOG_LEVEL_ORDER[clientLevel]) return;
|
||||
if (flushing) return; // don't log anything produced by the flush itself
|
||||
if (entry.path && entry.path.startsWith(ENDPOINT)) return; // never log the log call
|
||||
queue.push({ ...entry, at: entry.at ?? new Date().toISOString() });
|
||||
if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
/** POST the queued entries. Raw fetch (not apiFetch) so a failure can't recurse. A
|
||||
* failed flush silently re-queues nothing — diagnostics are best-effort, never fatal. */
|
||||
async function flush(): Promise<void> {
|
||||
if (queue.length === 0) return;
|
||||
const entries = queue.splice(0, queue.length);
|
||||
flushing = true;
|
||||
try {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
const csrf = readCookie(CSRF_COOKIE);
|
||||
if (csrf) headers[CSRF_HEADER] = csrf;
|
||||
await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ entries }),
|
||||
keepalive: true,
|
||||
});
|
||||
} catch {
|
||||
// Drop on failure — we must not re-log (loop) nor grow unbounded.
|
||||
} finally {
|
||||
flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). */
|
||||
function flushBeacon(): void {
|
||||
if (queue.length === 0) return;
|
||||
const entries = queue.splice(0, queue.length);
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify({ entries })], { type: "application/json" });
|
||||
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
|
||||
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
|
||||
// this path degrades to "lost on unload" — acceptable for diagnostics.
|
||||
navigator.sendBeacon(ENDPOINT, blob);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Record a FAILED API request (called from apiFetch's error path). Always high-signal. */
|
||||
export function logFailedRequest(info: {
|
||||
path: string;
|
||||
method: string;
|
||||
status: number;
|
||||
error?: string;
|
||||
requestId?: string;
|
||||
}): void {
|
||||
logClient({
|
||||
level: "error",
|
||||
message: `${info.method} ${info.path} → ${info.status}${info.error ? `: ${info.error}` : ""}`,
|
||||
httpStatus: info.status,
|
||||
path: info.path,
|
||||
context: { kind: "request_failed", method: info.method, requestId: info.requestId },
|
||||
});
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
/** Wire global handlers once, at app startup. Idempotent. */
|
||||
export function installClientLogging(): void {
|
||||
if (installed || typeof window === "undefined") return;
|
||||
installed = true;
|
||||
|
||||
// Uncaught runtime errors.
|
||||
window.addEventListener("error", (e: ErrorEvent) => {
|
||||
logClient({
|
||||
level: "error",
|
||||
message: e.message || "uncaught error",
|
||||
stack: e.error?.stack,
|
||||
path: location.pathname,
|
||||
context: {
|
||||
kind: "window_error",
|
||||
filename: e.filename,
|
||||
line: e.lineno,
|
||||
col: e.colno,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Unhandled promise rejections.
|
||||
window.addEventListener("unhandledrejection", (e: PromiseRejectionEvent) => {
|
||||
const reason = e.reason;
|
||||
const message =
|
||||
reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "unhandled rejection";
|
||||
logClient({
|
||||
level: "error",
|
||||
message,
|
||||
stack: reason instanceof Error ? reason.stack : undefined,
|
||||
path: location.pathname,
|
||||
context: { kind: "unhandled_rejection" },
|
||||
});
|
||||
});
|
||||
|
||||
// console.warn / console.error → only forwarded at debug/trace (noisy otherwise).
|
||||
const origWarn = console.warn.bind(console);
|
||||
const origError = console.error.bind(console);
|
||||
console.warn = (...args: unknown[]) => {
|
||||
origWarn(...args);
|
||||
if (consoleForwardEnabled()) {
|
||||
logClient({ level: "warn", message: stringifyArgs(args), path: location.pathname, context: { kind: "console" } });
|
||||
}
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
origError(...args);
|
||||
if (consoleForwardEnabled()) {
|
||||
logClient({ level: "error", message: stringifyArgs(args), path: location.pathname, context: { kind: "console" } });
|
||||
}
|
||||
};
|
||||
|
||||
// Flush on tab hide / unload.
|
||||
window.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") flushBeacon();
|
||||
});
|
||||
window.addEventListener("pagehide", flushBeacon);
|
||||
}
|
||||
|
||||
function stringifyArgs(args: unknown[]): string {
|
||||
return args
|
||||
.map((a) => (a instanceof Error ? a.message : typeof a === "string" ? a : safeStringify(a)))
|
||||
.join(" ")
|
||||
.slice(0, 2000);
|
||||
}
|
||||
|
||||
function safeStringify(v: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,20 @@ import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import "./lib/i18n/index.js"; // initialize i18next before the app renders
|
||||
import { App } from "./App.js";
|
||||
import { ErrorBoundary } from "./lib/ErrorBoundary.js";
|
||||
import { installClientLogging } from "./lib/logger.js";
|
||||
|
||||
// Capture uncaught errors / rejections / console noise → backend log store, before
|
||||
// the app mounts so even an early crash is reported. See lib/logger.ts.
|
||||
installClientLogging();
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("root element not found");
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { SiteSettings } from "./SiteSettings.js";
|
||||
import { UsersManager } from "./UsersManager.js";
|
||||
import { RolesManager } from "./RolesManager.js";
|
||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||
import { LogsViewer } from "./LogsViewer.js";
|
||||
|
||||
// Code-based TanStack Router (no file-based codegen — the app is small enough that
|
||||
// an explicit tree is clearer). The router context carries the signed-in user and
|
||||
@@ -84,6 +85,7 @@ function SetupLayout() {
|
||||
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
||||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||||
{show("shift:read") && <SetupTab to="/setup/shifts" label={t("nav.shifts")} />}
|
||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
@@ -363,6 +365,7 @@ const SETUP_TABS: { to: string; perm: Permission }[] = [
|
||||
{ to: "/setup/users", perm: "user:read" },
|
||||
{ to: "/setup/roles", perm: "role:read" },
|
||||
{ to: "/setup/shifts", perm: "shift:read" },
|
||||
{ to: "/setup/logs", perm: "log:read" },
|
||||
];
|
||||
|
||||
// /setup is a LAYOUT route (tab bar + <Outlet>); the config screens are its
|
||||
@@ -437,6 +440,14 @@ const shiftsHistoryRoute = createRoute({
|
||||
},
|
||||
});
|
||||
|
||||
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
|
||||
const logsRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "logs",
|
||||
beforeLoad: ({ context }) => requirePerm("log:read")(context),
|
||||
component: LogsViewer,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
boothRoute,
|
||||
@@ -450,6 +461,7 @@ const routeTree = rootRoute.addChildren([
|
||||
usersRoute,
|
||||
rolesRoute,
|
||||
shiftsHistoryRoute,
|
||||
logsRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user