Files
parking_solution/apps/web/src/LogsViewer.tsx
T
julian cce99aadfd
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m42s
CI / check (push) Successful in 37s
fix(web): booth UI/UX pass — readable font scaling + booth layout/report clarity
A round of operator-facing fixes on the booth screen, shift views, and the
font-scale control. (Follows the font-scale feature in f706726, which used CSS
`zoom` — reverted here for the rem approach below.)

Font scaling (the A−/A+ control now actually works without breaking layout):
- The control scaled via CSS `zoom`, which also scaled viewport-locked containers
  (h-screen frame, max-h-[90vh] modals) so at 130% modal headers/footers were
  pushed off-screen. Reworked to scale TEXT only: converted every `text-[Npx]`
  font utility to rem across the web app (~230 sites in 25 files + the
  .label/.hint/.btn component classes + body in index.css; 16px root, so 100% is
  visually identical), and applyFontScale now sets the ROOT font-size. vh/h-screen
  layout stays put, so chrome never clips; tall content scrolls its own container.
  Verified at 130%: text 12px→15.6px while the frame stayed viewport-height.

Live feed (event rows):
- Plate, badges and reason now flow inline after the identity and wrap only when
  the row runs out of width — no more forced second line when there's empty space.
- Dropped the redundant TARGË via-badge (the plate chip already conveys it).
- Removed the Direction filter group (Hyrje/Dalje) — it duplicated the entry/exit
  options already in the Type filter.

Active sessions:
- Rebuilt as a real table (Ticket/subscriber · Plate · Entry · Elapsed) so columns
  align and long values (subscriber names, ticket ids) no longer truncate.
- Dropped the status column (an unpaid transient is normal; a subscriber shows ★ +
  name; overstay keeps a row tint). Removed the now-redundant status filter; only
  the Transient/Subscriber filter remains. Plate is now searchable (uses s.plate).

Shift report (close-shift modal + Shift History + printed Z-report slip):
- Removed the confusing `shitje` (subscription-sales) sub-line — Abonime is the
  total; only the out-of-window part is broken out. subscriptionSalesMinor stays in
  the signed payload (audit data), just not displayed/printed.
- Show the inherited opening cash ("Arka fillestare") above the expected drawer, so
  opening + cash-taken = expected reads clearly. Money values no longer line-wrap.

Subscription edit modal:
- Fixed the 2-col grid alignment: a lone "only one version" cell was shifting every
  following row by one column — it now emits a full label+value pair.

Removed orphaned i18n keys (fStatus*, fDir*, srcSubSales) from sq+en (parity kept).
Full workspace build/lint/test green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 15:15:09 +02:00

160 lines
6.2 KiB
TypeScript

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-[0.75rem] ${
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-[0.6875rem] 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-[0.6875rem] 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-[0.6875rem] 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="">
<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-[0.75rem] text-term-muted">{t("common.loading")}</div>
) : logs.length === 0 ? (
<div className="p-3 text-[0.75rem] 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-[0.625rem] 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>
);
}