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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user