Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30e7fe85de | |||
| bfb6ab0b36 |
@@ -11,7 +11,7 @@ export type Direction = "entry" | "exit" | "both";
|
||||
export type FlowDirection = "entry" | "exit";
|
||||
|
||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
||||
* and (optionally) the input terminal its entry button is wired to. */
|
||||
* and (optionally) the input terminals its entry button + presence loop are wired to. */
|
||||
export interface RelaySpec {
|
||||
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
||||
readonly relay: number;
|
||||
@@ -19,6 +19,21 @@ export interface RelaySpec {
|
||||
/** 1-based input terminal of the entry button that fires this relay (transient
|
||||
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
|
||||
readonly button?: number;
|
||||
/**
|
||||
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
|
||||
* Two modes, chosen by what barrier feedback exists at this lane:
|
||||
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
|
||||
* 1-based input terminal of an induction loop / barrier presence signal on THIS
|
||||
* controller. A press prints only while a car is present, and no second ticket
|
||||
* issues until the loop CLEARS (car drove in) and a new car re-occupies it. This
|
||||
* makes one-car-one-ticket physical.
|
||||
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
|
||||
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
|
||||
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
|
||||
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
||||
*/
|
||||
readonly presenceInput?: number;
|
||||
readonly entryCooldownSec?: number;
|
||||
}
|
||||
|
||||
/** Access controller config (the `relays[]` map + connection fields). */
|
||||
@@ -38,11 +53,17 @@ interface BoundConfig {
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
|
||||
/** A resolved barrier: the controller row + the specific relay to pulse. */
|
||||
/** A resolved barrier: the controller row + the specific relay to pulse. Carries the
|
||||
* transient-entry anti-double-press config (presence loop / cooldown) when resolved
|
||||
* from a button press, so the entry flow can enforce one-car-one-ticket. */
|
||||
export interface ResolvedRelay {
|
||||
readonly controller: DeviceRow;
|
||||
readonly relay: number;
|
||||
readonly direction: Direction;
|
||||
/** 1-based presence-loop input gating this relay's entry (when wired). */
|
||||
readonly presenceInput?: number;
|
||||
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */
|
||||
readonly entryCooldownSec?: number;
|
||||
}
|
||||
|
||||
/** All enabled access controller rows. */
|
||||
@@ -76,6 +97,31 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
|
||||
const spec = relaysOf(row).find((r) => r.button === terminal);
|
||||
if (!spec) return null;
|
||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||
return {
|
||||
controller: row,
|
||||
relay: spec.relay,
|
||||
direction: spec.direction,
|
||||
presenceInput: spec.presenceInput,
|
||||
entryCooldownSec: spec.entryCooldownSec,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with
|
||||
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input.
|
||||
* Lets the entry flow track "a car is physically at this entry barrier" so it issues
|
||||
* exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise.
|
||||
*/
|
||||
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||
const row = db
|
||||
.select()
|
||||
.from(devices)
|
||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
||||
if (!spec) return null;
|
||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||
return { controller: row, relay: spec.relay, direction: spec.direction };
|
||||
}
|
||||
|
||||
|
||||
+158
-12
@@ -1,5 +1,5 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import { eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||
import { randomInt, randomUUID } from "node:crypto";
|
||||
import { deviceEvents as deviceEventsTable, eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||
import {
|
||||
NoPrinterAvailableError,
|
||||
printWithFailover,
|
||||
@@ -15,7 +15,7 @@ import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent } from "./device-events.js";
|
||||
import { getOccupancy } from "./occupancy.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { devicesByDirection, relayForButton, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
|
||||
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
|
||||
@@ -35,6 +35,31 @@ import { snapshotAsync } from "./snapshot.js";
|
||||
//
|
||||
// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
|
||||
// (fail) sign anomaly, stop.
|
||||
//
|
||||
// ONE CAR = ONE TICKET (anti-double-press). The entry button can be physically held
|
||||
// or mashed; without a guard each press mints a fresh ticket + signed vehicle_entry
|
||||
// (corrupting occupancy and letting a transient shop the cheapest ticket at exit). The
|
||||
// guard is per-relay and CONFIGURED on the relay spec (config.relays[]), chosen by what
|
||||
// barrier feedback exists at the lane:
|
||||
// - PRESENCE loop (preferred): `presenceInput` ties ticketing to a real vehicle. A
|
||||
// press prints only while a car is present, and NO second ticket issues until the
|
||||
// loop CLEARS (car drove in) and a new car re-occupies it. We observe the loop's
|
||||
// input edges to track presence + "armed" per relay.
|
||||
// - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on
|
||||
// the relay for N seconds after a ticket. A timer — mitigation, not a guarantee.
|
||||
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
|
||||
// See wiki/concepts/entry-double-press.md.
|
||||
|
||||
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
|
||||
interface RelayGuardState {
|
||||
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
|
||||
lastTicketAt: number;
|
||||
/** PRESENCE mode: is a vehicle currently on the loop? (from loop input edges) */
|
||||
present: boolean;
|
||||
/** PRESENCE mode: ready to issue a ticket for a NEW car. Set false after a ticket
|
||||
* prints; re-armed when the loop CLEARS (the car drove through). */
|
||||
armed: boolean;
|
||||
}
|
||||
|
||||
export class EntryFlow {
|
||||
readonly #db: Db;
|
||||
@@ -42,6 +67,8 @@ export class EntryFlow {
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
/** Guard against double-fire from the same physical press (on edge only). */
|
||||
readonly #inFlight = new Set<string>();
|
||||
/** Per-relay one-car-one-ticket state (presence + cooldown), keyed controllerId:relay. */
|
||||
readonly #guard = new Map<string, RelayGuardState>();
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
@@ -49,10 +76,20 @@ export class EntryFlow {
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Handle a device input edge. Acts only on the rising ("on") edge of an entry
|
||||
* button — an input terminal mapped to an entry relay on its controller. */
|
||||
/** Handle a device input edge. Two kinds of edge matter to this flow:
|
||||
* (1) an ENTRY BUTTON press (rising edge) → run entry, subject to the per-relay
|
||||
* anti-double-press guard; (2) a PRESENCE LOOP edge (either direction) → update
|
||||
* presence state so the guard knows when a car arrives/leaves. The same physical
|
||||
* input is never both, so we resolve each independently. */
|
||||
async onInput(e: DeviceInputEvent): Promise<void> {
|
||||
if (e.edge !== "on") return; // release edge is just telemetry
|
||||
// Presence-loop edge (both directions matter): keep the per-relay state current.
|
||||
const presence = relayForPresence(this.#db, e.deviceId, e.input);
|
||||
if (presence) {
|
||||
this.#onPresenceEdge(presence, e.edge);
|
||||
return; // a loop input is not a button — nothing else to do
|
||||
}
|
||||
|
||||
if (e.edge !== "on") return; // for buttons, the release edge is just telemetry
|
||||
|
||||
// The firing device must be an access controller, and the pressed input terminal
|
||||
// must map to an ENTRY (or both) relay — that's an entry button. Anything else
|
||||
@@ -60,6 +97,14 @@ export class EntryFlow {
|
||||
const resolved = relayForButton(this.#db, e.deviceId, e.input);
|
||||
if (!resolved) return;
|
||||
|
||||
// ANTI-DOUBLE-PRESS: is this press allowed to issue a ticket? (presence/cooldown)
|
||||
const suppressed = this.#suppressReason(resolved);
|
||||
if (suppressed) {
|
||||
this.#recordSuppressedPress(e, resolved, suppressed);
|
||||
this.#logger.info(`entry press suppressed (${this.#relayKey(resolved)}): ${suppressed}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${e.deviceId}:${e.input}`;
|
||||
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
|
||||
this.#inFlight.add(key);
|
||||
@@ -72,6 +117,88 @@ export class EntryFlow {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable per-relay key for the guard map. */
|
||||
#relayKey(r: ResolvedRelay): string {
|
||||
return `${r.controller.id}:${r.relay}`;
|
||||
}
|
||||
|
||||
/** Lazily get (or create) the guard state for a relay. New relays start ARMED and
|
||||
* with no car present, so the first press on a fresh lane works immediately. */
|
||||
#guardState(r: ResolvedRelay): RelayGuardState {
|
||||
const key = this.#relayKey(r);
|
||||
let s = this.#guard.get(key);
|
||||
if (!s) {
|
||||
s = { lastTicketAt: 0, present: false, armed: true };
|
||||
this.#guard.set(key, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Apply a presence-loop edge to a relay's state. The car ARRIVING re-arms ticketing;
|
||||
* the car LEAVING the loop (after its entry) re-arms for the NEXT car. */
|
||||
#onPresenceEdge(r: ResolvedRelay, edge: "on" | "off"): void {
|
||||
const s = this.#guardState(r);
|
||||
if (edge === "on") {
|
||||
s.present = true; // a vehicle is at the barrier
|
||||
} else {
|
||||
// Loop cleared: the car drove through (or backed off). Re-arm for the next car —
|
||||
// this is the gate that makes a *new* car necessary before another ticket.
|
||||
s.present = false;
|
||||
s.armed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
|
||||
* PRESENCE mode is authoritative when a loop is wired; otherwise COOLDOWN; else no
|
||||
* guard (legacy). The two can coexist — presence first, cooldown as a backstop. */
|
||||
#suppressReason(r: ResolvedRelay): string | null {
|
||||
const s = this.#guardState(r);
|
||||
|
||||
if (typeof r.presenceInput === "number") {
|
||||
// Physical one-car-one-ticket: a car must be present AND we must be armed (no
|
||||
// ticket already issued for this still-present car).
|
||||
if (!s.present) return "no vehicle at the barrier (presence loop clear)";
|
||||
if (!s.armed) return "ticket already issued for the car at the barrier";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
|
||||
const elapsed = Date.now() - s.lastTicketAt;
|
||||
if (elapsed < r.entryCooldownSec * 1000) {
|
||||
const remain = Math.ceil((r.entryCooldownSec * 1000 - elapsed) / 1000);
|
||||
return `within ${r.entryCooldownSec}s entry cooldown (${remain}s left)`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op,
|
||||
* not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */
|
||||
#recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void {
|
||||
try {
|
||||
this.#db
|
||||
.insert(deviceEventsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
deviceId: e.deviceId,
|
||||
category: "access",
|
||||
kind: "input",
|
||||
detail: {
|
||||
driverId: e.driverId,
|
||||
input: e.input,
|
||||
edge: e.edge,
|
||||
entrySuppressed: true,
|
||||
relay: r.relay,
|
||||
reason,
|
||||
},
|
||||
occurredAt: e.at,
|
||||
})
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`suppressed-press telemetry insert failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async #runEntry(resolved: ResolvedRelay): Promise<void> {
|
||||
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
|
||||
// no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT
|
||||
@@ -80,14 +207,20 @@ export class EntryFlow {
|
||||
// capacity later. See wiki/concepts/capacity-occupancy.md.
|
||||
const occ = getOccupancy(this.#db);
|
||||
if (occ.full) {
|
||||
// No ticket id exists for a refused entry, so mint a synthetic ref to key the
|
||||
// anomaly + its evidence snapshot together. The operator wants the photo of WHO
|
||||
// was turned away (a fraud/dispute signal), so we still fire the entry camera.
|
||||
const refusedRef = `REFUSED-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: refusedRef,
|
||||
payload: {
|
||||
...reasonPayload("entry.refused.full", { count: occ.count, capacity: occ.capacity ?? 0 }),
|
||||
entryRefused: true,
|
||||
full: true,
|
||||
},
|
||||
});
|
||||
this.#fireSnapshot("entry", refusedRef);
|
||||
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
|
||||
return;
|
||||
}
|
||||
@@ -103,6 +236,13 @@ export class EntryFlow {
|
||||
d.printTicket(ticket),
|
||||
);
|
||||
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`);
|
||||
// ONE CAR = ONE TICKET: a ticket is now out for the car at this barrier. Disarm +
|
||||
// stamp the cooldown so a repeat press (held button / mashing) issues no second
|
||||
// ticket. PRESENCE mode re-arms when the loop clears (car drove in); COOLDOWN mode
|
||||
// re-allows after entryCooldownSec. Done on the print success, NOT the open.
|
||||
const guard = this.#guardState(resolved);
|
||||
guard.lastTicketAt = Date.now();
|
||||
guard.armed = false;
|
||||
} catch (err) {
|
||||
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
|
||||
// failed attempt is in the tamper-evident record for the operator.
|
||||
@@ -113,6 +253,8 @@ export class EntryFlow {
|
||||
identity: ticketId,
|
||||
payload: { ...reasonPayload("entry.held.noTicket", { detail: reason }), ticketPrinted: false },
|
||||
});
|
||||
// Capture who is held at the barrier (evidence for the operator handling the car).
|
||||
this.#fireSnapshot("entry", ticketId);
|
||||
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||
return;
|
||||
}
|
||||
@@ -146,12 +288,7 @@ export class EntryFlow {
|
||||
|
||||
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
|
||||
// a camera failure must not delay or block the already-open barrier).
|
||||
void snapshotAsync({
|
||||
db: this.#db,
|
||||
direction: "entry",
|
||||
identity: ticketId,
|
||||
logger: this.#logger,
|
||||
}).catch((err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`));
|
||||
this.#fireSnapshot("entry", ticketId);
|
||||
|
||||
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
||||
// just a fast read-model, never the source of truth).
|
||||
@@ -167,6 +304,15 @@ export class EntryFlow {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
|
||||
* Used on both the OPEN path and the refused/held anomaly paths — a turned-away or
|
||||
* held car is exactly when the operator wants the photo. */
|
||||
#fireSnapshot(direction: "entry", identity: string): void {
|
||||
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger }).catch((err) =>
|
||||
this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a live access adapter from a resolved controller row, or null. */
|
||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||
const driver = registry.get(row.driverId);
|
||||
|
||||
@@ -99,6 +99,7 @@ export class ExitFlow {
|
||||
if (!view || !view.open) {
|
||||
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
|
||||
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
|
||||
return { ok: false, status: view ? "closed" : "no_session", reason: rp.reason };
|
||||
}
|
||||
@@ -112,6 +113,7 @@ export class ExitFlow {
|
||||
if (!freeGrace && (!paid || !withinGrace)) {
|
||||
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
|
||||
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
|
||||
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
||||
}
|
||||
@@ -274,6 +276,7 @@ export class ExitFlow {
|
||||
identity: e.value,
|
||||
payload: { ...rp, exitRefused: true },
|
||||
});
|
||||
this.#fireExitSnapshot(e.value);
|
||||
this.#logger.warn(`exit refused: no open session for ${e.value}`);
|
||||
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||
}
|
||||
@@ -314,6 +317,7 @@ export class ExitFlow {
|
||||
identity: e.value,
|
||||
payload: { ...rp, exitRefused: true, sessionRef: e.value },
|
||||
});
|
||||
this.#fireExitSnapshot(e.value);
|
||||
this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`);
|
||||
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, appLogs, desc, eq, sql, type Db } from "@parking/db";
|
||||
import {
|
||||
LOG_LEVEL_ORDER,
|
||||
type AppLogRecord,
|
||||
type ClientLogInput,
|
||||
type LogLevel,
|
||||
type LogSource,
|
||||
} from "@parking/shared";
|
||||
|
||||
// Application/diagnostic LOG SINK — the host-side store behind the third log stream
|
||||
// (app_logs), distinct from the signed ledger and device telemetry. It persists:
|
||||
// - BACKEND warn/error/fatal, fed by a pino stream (see pinoDbStream) so any
|
||||
// app.log.warn/error lands in the DB without changing call sites.
|
||||
// - FRONTEND errors POSTed to /api/logs (failed requests, uncaught errors).
|
||||
// Everything here is UNSIGNED + prunable. Pruned by age AND a row cap so an offline
|
||||
// appliance with finite disk can't be filled by a log storm. See
|
||||
// wiki/concepts/app-logs.md, decisions/event-streams-split.md.
|
||||
|
||||
/** Only warn and above are persisted from the backend (info/debug stay stdout-only). */
|
||||
const BACKEND_PERSIST_MIN: LogLevel = "warn";
|
||||
|
||||
/** Defensive caps so one runaway log can't bloat a row (chars). */
|
||||
const MAX_MESSAGE = 4_000;
|
||||
const MAX_STACK = 16_000;
|
||||
const MAX_CONTEXT_JSON = 16_000;
|
||||
|
||||
export interface LogRetention {
|
||||
/** Delete logs older than this many days. */
|
||||
readonly maxAgeDays: number;
|
||||
/** Hard cap on total rows — the oldest beyond this are pruned. */
|
||||
readonly maxRows: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_RETENTION: LogRetention = {
|
||||
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 30),
|
||||
maxRows: Number(process.env.LOG_RETENTION_MAX_ROWS ?? 50_000),
|
||||
};
|
||||
|
||||
function clamp(s: string | null | undefined, max: number): string | null {
|
||||
if (s == null) return null;
|
||||
return s.length > max ? s.slice(0, max) : s;
|
||||
}
|
||||
|
||||
/** Serialize context to JSON, bounded — never throw on a circular/huge object. */
|
||||
function safeContext(ctx: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
|
||||
if (ctx == null) return null;
|
||||
try {
|
||||
const json = JSON.stringify(ctx);
|
||||
if (json.length <= MAX_CONTEXT_JSON) return ctx;
|
||||
return { _truncated: true, preview: json.slice(0, MAX_CONTEXT_JSON) };
|
||||
} catch {
|
||||
return { _unserializable: true };
|
||||
}
|
||||
}
|
||||
|
||||
export class LogService {
|
||||
readonly #db: Db;
|
||||
readonly #retention: LogRetention;
|
||||
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
|
||||
#writing = false;
|
||||
|
||||
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
|
||||
this.#db = db;
|
||||
this.#retention = retention;
|
||||
}
|
||||
|
||||
/** Low-level insert. Best-effort: a logging failure must never break a request or
|
||||
* recurse (a DB error here would otherwise log → insert → error → log …). */
|
||||
#insert(row: {
|
||||
level: LogLevel;
|
||||
source: LogSource;
|
||||
message: string;
|
||||
context?: Record<string, unknown> | null;
|
||||
httpStatus?: number | null;
|
||||
path?: string | null;
|
||||
stack?: string | null;
|
||||
userId?: string | null;
|
||||
userAgent?: string | null;
|
||||
createdAt?: string;
|
||||
}): void {
|
||||
if (this.#writing) return;
|
||||
this.#writing = true;
|
||||
try {
|
||||
this.#db
|
||||
.insert(appLogs)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
level: row.level,
|
||||
source: row.source,
|
||||
message: clamp(row.message, MAX_MESSAGE) ?? "",
|
||||
context: safeContext(row.context),
|
||||
httpStatus: row.httpStatus ?? null,
|
||||
path: clamp(row.path, 512),
|
||||
stack: clamp(row.stack, MAX_STACK),
|
||||
userId: row.userId ?? null,
|
||||
userAgent: clamp(row.userAgent, 512),
|
||||
createdAt: row.createdAt ?? new Date().toISOString(),
|
||||
})
|
||||
.run();
|
||||
} catch {
|
||||
// Swallow — diagnostics must never take down the path they observe. (Can't log
|
||||
// it; that's the recursion we're guarding against.)
|
||||
} finally {
|
||||
this.#writing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a BACKEND log line (called by the pino stream). Below warn is dropped. */
|
||||
recordBackend(level: LogLevel, message: string, context?: Record<string, unknown> | null): void {
|
||||
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
|
||||
this.#insert({ level, source: "backend", message, context });
|
||||
}
|
||||
|
||||
/** Persist a FRONTEND-reported log (from POST /api/logs). The server stamps the
|
||||
* user + receive time; the client supplies level/message/context. */
|
||||
recordClient(
|
||||
input: ClientLogInput,
|
||||
meta: { userId?: string | null; userAgent?: string | null },
|
||||
): void {
|
||||
this.#insert({
|
||||
level: input.level,
|
||||
source: "frontend",
|
||||
message: input.message,
|
||||
context: input.context ?? null,
|
||||
httpStatus: input.httpStatus ?? null,
|
||||
path: input.path ?? null,
|
||||
stack: input.stack ?? null,
|
||||
userId: meta.userId ?? null,
|
||||
userAgent: meta.userAgent ?? null,
|
||||
// Keep the client's capture time in context for ordering; createdAt is server time.
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Read recent logs, newest first, with optional level/source/since filters. */
|
||||
query(opts: {
|
||||
limit: number;
|
||||
level?: LogLevel;
|
||||
source?: LogSource;
|
||||
since?: string;
|
||||
}): AppLogRecord[] {
|
||||
const conds = [];
|
||||
if (opts.level) conds.push(eq(appLogs.level, opts.level));
|
||||
if (opts.source) conds.push(eq(appLogs.source, opts.source));
|
||||
if (opts.since) conds.push(sql`${appLogs.createdAt} >= ${opts.since}`);
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(appLogs)
|
||||
.where(conds.length ? and(...conds) : undefined)
|
||||
.orderBy(desc(appLogs.createdAt))
|
||||
.limit(opts.limit)
|
||||
.all();
|
||||
return rows as unknown as AppLogRecord[];
|
||||
}
|
||||
|
||||
/** Prune by age then by row cap. Returns how many rows were deleted. Safe to call
|
||||
* on a timer; cheap (indexed on created_at). */
|
||||
prune(): number {
|
||||
let deleted = 0;
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - this.#retention.maxAgeDays * 86_400_000).toISOString();
|
||||
const byAge = this.#db.delete(appLogs).where(sql`${appLogs.createdAt} < ${cutoff}`).run();
|
||||
deleted += byAge.changes ?? 0;
|
||||
|
||||
// Row cap: keep the newest maxRows, delete the rest. One subquery — find the
|
||||
// created_at boundary of the keep-window, delete older.
|
||||
const total = this.#db.select({ c: sql<number>`count(*)` }).from(appLogs).get();
|
||||
const count = total?.c ?? 0;
|
||||
if (count > this.#retention.maxRows) {
|
||||
const boundary = this.#db
|
||||
.select({ createdAt: appLogs.createdAt })
|
||||
.from(appLogs)
|
||||
.orderBy(desc(appLogs.createdAt))
|
||||
.limit(1)
|
||||
.offset(this.#retention.maxRows - 1)
|
||||
.get();
|
||||
if (boundary) {
|
||||
const byCap = this.#db
|
||||
.delete(appLogs)
|
||||
.where(sql`${appLogs.createdAt} < ${boundary.createdAt}`)
|
||||
.run();
|
||||
deleted += byCap.changes ?? 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pino-compatible write stream that forwards BACKEND warn+ lines into the LogService.
|
||||
* Pino writes one JSON object per line to this stream; we parse, map the numeric level
|
||||
* to a name, and persist. Returned as `{ write }` so it can be passed as pino's stream.
|
||||
* stdout still receives the same line (we tee), so console logging is unchanged.
|
||||
*/
|
||||
export function pinoDbStream(
|
||||
service: LogService,
|
||||
tee: NodeJS.WritableStream,
|
||||
): { write: (line: string) => void } {
|
||||
const NUM_TO_LEVEL: Record<number, LogLevel> = {
|
||||
10: "trace",
|
||||
20: "debug",
|
||||
30: "info",
|
||||
40: "warn",
|
||||
50: "error",
|
||||
60: "fatal",
|
||||
};
|
||||
return {
|
||||
write(line: string): void {
|
||||
// Always tee to the original destination first (don't lose stdout logging).
|
||||
try {
|
||||
tee.write(line);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(line) as {
|
||||
level?: number;
|
||||
msg?: string;
|
||||
err?: { stack?: string; message?: string };
|
||||
[k: string]: unknown;
|
||||
};
|
||||
const level = NUM_TO_LEVEL[obj.level ?? 30] ?? "info";
|
||||
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
|
||||
// Strip pino's noisy standard fields from the persisted context.
|
||||
const { level: _l, time: _t, pid: _p, hostname: _h, msg, ...rest } = obj;
|
||||
service.recordBackend(level, typeof msg === "string" ? msg : "", rest);
|
||||
} catch {
|
||||
// A non-JSON line (shouldn't happen with pino) — ignore for persistence.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { AppLogRecord, ClientLogInput, LogLevel } from "@parking/shared";
|
||||
import { requireAuth, requirePermission } from "../auth.js";
|
||||
import type { LogService } from "../log-service.js";
|
||||
|
||||
// Application/diagnostic logs (app_logs) — see wiki/concepts/app-logs.md. Two ends:
|
||||
// - POST /api/logs : the FRONTEND ships its errors here (failed requests, uncaught
|
||||
// exceptions). Any signed-in user may write (it's their own
|
||||
// browser's diagnostics); CSRF still applies (mutation).
|
||||
// - GET /api/logs : read the store — gated by `log:read` (admin/diagnostic role).
|
||||
// Writes go through the shared LogService (bounded, best-effort, reentrancy-guarded);
|
||||
// the DB sink for BACKEND warn+ is wired at the pino stream, not here.
|
||||
|
||||
const LEVELS: ReadonlySet<string> = new Set(["trace", "debug", "info", "warn", "error", "fatal"]);
|
||||
|
||||
/** Cap a single ingest batch so a misbehaving client can't flood the store. */
|
||||
const MAX_BATCH = 50;
|
||||
|
||||
function isValidEntry(e: unknown): e is ClientLogInput {
|
||||
if (!e || typeof e !== "object") return false;
|
||||
const o = e as Record<string, unknown>;
|
||||
return typeof o.message === "string" && typeof o.level === "string" && LEVELS.has(o.level);
|
||||
}
|
||||
|
||||
export async function logRoutes(app: FastifyInstance, logService: LogService): Promise<void> {
|
||||
// INGEST — accept one entry or a small batch ({ entries: [...] }). Returns 204.
|
||||
// Deliberately tolerant: it never 4xx's on a malformed entry (a client erroring
|
||||
// while reporting an error shouldn't get a second error) — invalid items are skipped.
|
||||
app.post<{ Body: ClientLogInput | { entries?: unknown[] } }>(
|
||||
"/api/logs",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const body = req.body as ClientLogInput | { entries?: unknown[] };
|
||||
const raw = Array.isArray((body as { entries?: unknown[] }).entries)
|
||||
? (body as { entries: unknown[] }).entries
|
||||
: [body];
|
||||
const userId = req.user?.sub ?? null;
|
||||
const userAgent = req.headers["user-agent"] ?? null;
|
||||
for (const entry of raw.slice(0, MAX_BATCH)) {
|
||||
if (!isValidEntry(entry)) continue;
|
||||
logService.recordClient(entry, { userId, userAgent });
|
||||
}
|
||||
reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
// READ — newest first, with optional level/source/since filters + a limit. The
|
||||
// booth Logs viewer calls this. Gated by log:read.
|
||||
app.get<{ Querystring: { limit?: string; level?: string; source?: string; since?: string } }>(
|
||||
"/api/logs",
|
||||
{ preHandler: requirePermission("log:read") },
|
||||
async (req): Promise<{ logs: AppLogRecord[] }> => {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 2000);
|
||||
const level = (req.query.level ?? "").trim();
|
||||
const source = (req.query.source ?? "").trim();
|
||||
const since = (req.query.since ?? "").trim();
|
||||
const logs = logService.query({
|
||||
limit,
|
||||
level: LEVELS.has(level) ? (level as LogLevel) : undefined,
|
||||
source: source === "frontend" || source === "backend" ? source : undefined,
|
||||
since: since || undefined,
|
||||
});
|
||||
return { logs };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import { CredentialCapture } from "./credential-capture.js";
|
||||
import { PrinterMonitor } from "./printer-monitor.js";
|
||||
import { DeviceMonitor } from "./device-monitor.js";
|
||||
import { buildSigner, buildVerifier } from "./signer.js";
|
||||
import { LogService, pinoDbStream } from "./log-service.js";
|
||||
import { logRoutes } from "./routes/logs.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { userRoutes } from "./routes/users.js";
|
||||
import { roleRoutes } from "./routes/roles.js";
|
||||
@@ -43,12 +45,20 @@ export interface BuildOptions {
|
||||
}
|
||||
|
||||
export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInstance> {
|
||||
const app = Fastify({
|
||||
logger: { level: process.env.LOG_LEVEL ?? "info" },
|
||||
});
|
||||
|
||||
// DB first — the logger's DB sink needs it before Fastify is constructed.
|
||||
const db = opts.db ?? createDb();
|
||||
|
||||
// Application-log store: a pino stream tees warn+ lines into app_logs (and still
|
||||
// writes them to stdout), so backend warnings/errors are queryable from the booth
|
||||
// alongside frontend errors. See log-service.ts + wiki/concepts/app-logs.md.
|
||||
const logService = new LogService(db);
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
stream: pinoDbStream(logService, process.stdout),
|
||||
},
|
||||
});
|
||||
|
||||
// Wire the RBAC permission resolver to this DB (route guards resolve a user's
|
||||
// role → permission set through it). See auth.ts.
|
||||
initAuth(db);
|
||||
@@ -188,6 +198,20 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||
await siteRoutes(app, db);
|
||||
|
||||
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
||||
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||
await logRoutes(app, logService);
|
||||
|
||||
// Periodic retention prune (age + row cap) so the log table stays bounded on the
|
||||
// offline appliance. Runs hourly; unref'd so it never holds the process open.
|
||||
const pruneTimer = setInterval(() => {
|
||||
const n = logService.prune();
|
||||
if (n > 0) app.log.debug(`pruned ${n} app_log rows`);
|
||||
}, 60 * 60 * 1000);
|
||||
pruneTimer.unref();
|
||||
logService.prune(); // once at startup
|
||||
app.addHook("onClose", async () => clearInterval(pruneTimer));
|
||||
|
||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||
// Record every input edge as unsigned telemetry, keyed to the device that fired
|
||||
// (provenance). No lane — the pool-of-spaces model has none. The entry flow
|
||||
|
||||
@@ -98,8 +98,11 @@ export class SubscriptionFlow {
|
||||
}
|
||||
|
||||
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
|
||||
// The physical side the reader sits at — used to fire the right camera on a refusal
|
||||
// that happens BEFORE we infer the entry/exit verb ("both" defers to entry).
|
||||
const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry";
|
||||
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
|
||||
if (!sub) return { accepted: false, reason: await this.#reject(m, "sub.refused.notFound") };
|
||||
if (!sub) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
|
||||
|
||||
// Validity: active + within the coverage window.
|
||||
const now = new Date().toISOString();
|
||||
@@ -108,7 +111,7 @@ export class SubscriptionFlow {
|
||||
(sub.validFrom != null && now < sub.validFrom) ||
|
||||
(sub.validTo != null && now > sub.validTo);
|
||||
if (invalid) {
|
||||
const reason = await this.#reject(m, "sub.refused.outOfWindow", { status: sub.status });
|
||||
const reason = await this.#reject(m, lane, "sub.refused.outOfWindow", { status: sub.status });
|
||||
return { accepted: false, reason };
|
||||
}
|
||||
|
||||
@@ -136,7 +139,7 @@ export class SubscriptionFlow {
|
||||
// subscription has NOTHING open, an exit read is a no-op anti-passback signal.
|
||||
const oldest = open[0];
|
||||
if (!oldest) {
|
||||
const reason = await this.#reject(m, "sub.refused.noSession");
|
||||
const reason = await this.#reject(m, "exit", "sub.refused.noSession");
|
||||
return { accepted: false, direction: "exit", reason };
|
||||
}
|
||||
const occurrenceId = oldest.identity;
|
||||
@@ -156,7 +159,7 @@ export class SubscriptionFlow {
|
||||
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. Mint a
|
||||
// fresh per-occurrence id so a fleet can have several open at once.
|
||||
if (sub.maxConcurrent != null && open.length >= sub.maxConcurrent) {
|
||||
const reason = await this.#reject(m, "sub.refused.atCapacity", {
|
||||
const reason = await this.#reject(m, "entry", "sub.refused.atCapacity", {
|
||||
inUse: open.length,
|
||||
max: sub.maxConcurrent,
|
||||
});
|
||||
@@ -227,10 +230,14 @@ export class SubscriptionFlow {
|
||||
return open;
|
||||
}
|
||||
|
||||
/** Sign a refused-subscription anomaly with a localizable reason code, and return
|
||||
* the rendered English reason for the caller's ReadOutcome. */
|
||||
/** Sign a refused-subscription anomaly with a localizable reason code, fire the
|
||||
* directional evidence camera, and return the rendered English reason for the
|
||||
* caller's ReadOutcome. `dir` is the lane the refusal happened at (entry/exit) so
|
||||
* the right camera captures the turned-away subscriber. `via` records which
|
||||
* credential was presented. */
|
||||
async #reject(
|
||||
m: SubscriptionMatch,
|
||||
dir: FlowDirection,
|
||||
code: ReasonCode,
|
||||
params?: Record<string, string | number>,
|
||||
): Promise<string> {
|
||||
@@ -239,24 +246,28 @@ export class SubscriptionFlow {
|
||||
type: "anomaly",
|
||||
identity: m.carKey,
|
||||
// `permitId`/`permitRefused` are the on-chain field names (immutable).
|
||||
payload: { ...rp, permitId: m.subscriptionId, permitRefused: true },
|
||||
payload: { ...rp, permitId: m.subscriptionId, permitRefused: true, via: m.via },
|
||||
});
|
||||
this.#fireSnapshot(dir, m.carKey);
|
||||
this.#logger.warn(`subscription refused (${m.carKey}): ${rp.reason}`);
|
||||
return rp.reason;
|
||||
}
|
||||
|
||||
/** Fire the directional camera(s) for a refused-subscription event; never awaited
|
||||
* (evidence, not a gate). The accepted entry/exit paths snapshot inside #open. */
|
||||
#fireSnapshot(dir: FlowDirection, identity: string): void {
|
||||
void snapshotAsync({ db: this.#db, direction: dir, identity, logger: this.#logger }).catch((err) =>
|
||||
this.#logger.error(`subscription snapshot error: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
if (access) await access.pulseOpen(resolved.relay);
|
||||
else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`);
|
||||
|
||||
// SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate).
|
||||
void snapshotAsync({
|
||||
db: this.#db,
|
||||
direction: dir,
|
||||
identity: carKey,
|
||||
logger: this.#logger,
|
||||
}).catch((err) => this.#logger.error(`subscription snapshot error: ${(err as Error).message}`));
|
||||
this.#fireSnapshot(dir, carKey);
|
||||
}
|
||||
|
||||
#closeCache(carKey: string): void {
|
||||
|
||||
@@ -91,6 +91,16 @@ function eventBadges(p: LedgerEvent["payload"]): string[] {
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** The i18n key for a subscriber's access medium (`via`), or null. Lets the activity
|
||||
* log show HOW a subscriber entered/left — QR code, RFID card/chip, or plate. */
|
||||
function viaKey(p: LedgerEvent["payload"]): string | null {
|
||||
if (!p) return null;
|
||||
if (p.via === "qr") return "booth.viaQr";
|
||||
if (p.via === "card") return "booth.viaCard";
|
||||
if (p.via === "plate") return "booth.viaPlate";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A short money summary for payment events (e.g. "350.00 ALL"). */
|
||||
function paymentSummary(p: LedgerEvent["payload"]): string | null {
|
||||
if (!p || typeof p.amountMinor !== "number" || !p.currency) return null;
|
||||
@@ -115,8 +125,9 @@ function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => v
|
||||
const reason = renderReason(p, t);
|
||||
const amount = paymentSummary(p);
|
||||
const badges = eventBadges(p);
|
||||
const via = viaKey(p);
|
||||
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
||||
const showDetail = detail != null || badges.length > 0;
|
||||
const showDetail = detail != null || badges.length > 0 || via != null;
|
||||
|
||||
// The whole row is a button → opens the event-detail modal (full payload + the
|
||||
// session's entry/exit snapshots). A grid keeps the time/label/identity/index
|
||||
@@ -144,6 +155,11 @@ function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => v
|
||||
{t(k)}
|
||||
</span>
|
||||
))}
|
||||
{via && (
|
||||
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
|
||||
{t(via)}
|
||||
</span>
|
||||
)}
|
||||
{detail && (
|
||||
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||
)}
|
||||
@@ -238,6 +254,11 @@ function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void
|
||||
</DetailRow>
|
||||
)}
|
||||
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
|
||||
{viaKey(p) && (
|
||||
<DetailRow label={t("booth.edVia")}>
|
||||
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
||||
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
||||
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -422,6 +422,8 @@ function DeviceForm({
|
||||
relay: r.relay,
|
||||
direction: r.direction,
|
||||
...(r.button ? { button: r.button } : {}),
|
||||
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
|
||||
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}),
|
||||
}));
|
||||
} else if (controllerId && boundRelay !== "") {
|
||||
out.controllerId = controllerId;
|
||||
@@ -698,6 +700,36 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{(r.direction === "entry" || r.direction === "both") && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
|
||||
{t("setup.presenceInput")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.presenceInput ?? ""}
|
||||
placeholder="—"
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) =>
|
||||
update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{(r.direction === "entry" || r.direction === "both") && !r.presenceInput && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
|
||||
{t("setup.entryCooldown")}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={r.entryCooldownSec ?? ""}
|
||||
placeholder="—"
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) =>
|
||||
update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{relays.length > 1 && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
||||
✕
|
||||
|
||||
+35
-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 {
|
||||
@@ -227,6 +254,11 @@ export interface RelaySpec {
|
||||
direction: Direction;
|
||||
/** Input terminal of the entry button that fires this relay (transient entry). */
|
||||
button?: number;
|
||||
/** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle
|
||||
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
|
||||
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
|
||||
presenceInput?: number;
|
||||
entryCooldownSec?: number;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
@@ -632,7 +664,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",
|
||||
@@ -159,6 +160,10 @@ export const en: Catalog = {
|
||||
edRawPayload: "Raw signed payload",
|
||||
edOccurrence: "Occurrence id",
|
||||
subscriber: "Subscriber",
|
||||
edVia: "Entry medium",
|
||||
viaQr: "QR code",
|
||||
viaCard: "RFID card/chip",
|
||||
viaPlate: "Plate",
|
||||
},
|
||||
// Localized messages for the signed REASON_CODES (see @parking/shared). Keys MUST
|
||||
// match the codes 1:1; {{param}} placeholders are filled from the event's
|
||||
@@ -285,6 +290,12 @@ export const en: Catalog = {
|
||||
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
||||
relay: "Relay",
|
||||
entryButtonTerminal: "Entry button on terminal",
|
||||
presenceInput: "Presence loop (terminal)",
|
||||
presenceInputHint:
|
||||
"Input terminal the vehicle-presence loop / barrier feedback is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the loop clears (the car drove in) and a new car re-occupies it. Preferred mode.",
|
||||
entryCooldown: "Cooldown after ticket (s)",
|
||||
entryCooldownHint:
|
||||
"When there's no presence loop: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
||||
addRelay: "+ Add relay",
|
||||
whichBarrier: "Which barrier does this device serve?",
|
||||
controller: "Controller",
|
||||
@@ -482,6 +493,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",
|
||||
@@ -163,6 +164,10 @@ export const sq = {
|
||||
edRawPayload: "Të dhënat e papërpunuara të nënshkruara",
|
||||
edOccurrence: "ID e hyrjes",
|
||||
subscriber: "Abonent",
|
||||
edVia: "Mënyra e hyrjes",
|
||||
viaQr: "Kod QR",
|
||||
viaCard: "Kartë/çip RFID",
|
||||
viaPlate: "Targë",
|
||||
},
|
||||
// Mesazhet e përkthyera për REASON_CODES e nënshkruara (shih @parking/shared).
|
||||
// Çelësat përputhen 1:1 me kodet; {{param}} mbushet nga reasonParams i eventit.
|
||||
@@ -294,6 +299,12 @@ export const sq = {
|
||||
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
|
||||
relay: "Rele",
|
||||
entryButtonTerminal: "Butoni i hyrjes në terminalin",
|
||||
presenceInput: "Sensori i pranisë (terminali)",
|
||||
presenceInputHint:
|
||||
"Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.",
|
||||
entryCooldown: "Pritje pas biletës (sek)",
|
||||
entryCooldownHint:
|
||||
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
|
||||
addRelay: "+ Shto rele",
|
||||
// Binding picker.
|
||||
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
||||
@@ -495,6 +506,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,
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE `app_logs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`level` text NOT NULL,
|
||||
`source` text NOT NULL,
|
||||
`message` text NOT NULL,
|
||||
`context` text,
|
||||
`http_status` integer,
|
||||
`path` text,
|
||||
`stack` text,
|
||||
`user_id` text,
|
||||
`user_agent` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `app_logs_created_at_idx` ON `app_logs` (`created_at`);--> statement-breakpoint
|
||||
CREATE INDEX `app_logs_level_idx` ON `app_logs` (`level`);--> statement-breakpoint
|
||||
-- Grant the new log:read permission to the built-in admin role (enforcement is
|
||||
-- runtime-special-cased to ALL permissions, but the Roles UI lists the grid from these
|
||||
-- rows — keep it in sync). INSERT OR IGNORE: harmless if the row already exists.
|
||||
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES ('admin','log:read');
|
||||
@@ -64,6 +64,13 @@
|
||||
"when": 1781885100000,
|
||||
"tag": "0008_user_profile_theme",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1781885200000,
|
||||
"tag": "0009_app_logs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -356,6 +356,39 @@ export const sessions = sqliteTable("sessions", {
|
||||
lastEventIndex: integer("last_event_index"),
|
||||
});
|
||||
|
||||
// --- Application logs (diagnostics, UNSIGNED, prunable) ------------------
|
||||
// A THIRD stream, distinct from the signed ledger_events (business facts) and
|
||||
// device_events (hardware telemetry): operational/diagnostic logs for debugging the
|
||||
// appliance. Backend warn/error/fatal (a Pino sink) AND frontend errors land here —
|
||||
// failed requests, uncaught exceptions, rejected promises — so a booth problem is
|
||||
// queryable from one place on an offline box. Never signed, never reconciled, pruned
|
||||
// by age + row cap. See wiki/concepts/app-logs.md, event-streams-split.md.
|
||||
export const appLogs = sqliteTable("app_logs", {
|
||||
id: text("id").primaryKey(),
|
||||
// pino levels: trace|debug|info|warn|error|fatal. We persist warn+ from the backend.
|
||||
level: text("level", {
|
||||
enum: ["trace", "debug", "info", "warn", "error", "fatal"],
|
||||
}).notNull(),
|
||||
// Which side produced it — the booth UI or the host.
|
||||
source: text("source", { enum: ["frontend", "backend"] }).notNull(),
|
||||
message: text("message").notNull(),
|
||||
// Free-form structured detail: the failed request (path/method/status/body), the
|
||||
// error name, component, anything the caller attaches. Kept in one JSON column.
|
||||
context: text("context", { mode: "json" }).$type<Record<string, unknown>>(),
|
||||
// Pulled out of context for cheap filtering of the common "failed request" case.
|
||||
httpStatus: integer("http_status"),
|
||||
path: text("path"),
|
||||
// Captured stack trace, when there is one (uncaught errors / rejections).
|
||||
stack: text("stack"),
|
||||
// Who was logged in when it happened (frontend) / acted (backend), if known.
|
||||
userId: text("user_id"),
|
||||
// The browser/user-agent for a frontend log (triage which booth/device).
|
||||
userAgent: text("user_agent"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
export type UserRow = typeof users.$inferSelect;
|
||||
export type RoleRow = typeof roles.$inferSelect;
|
||||
export type RolePermissionRow = typeof rolePermissions.$inferSelect;
|
||||
@@ -372,3 +405,4 @@ export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSel
|
||||
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
||||
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||||
export type SessionRow = typeof sessions.$inferSelect;
|
||||
export type AppLogRow = typeof appLogs.$inferSelect;
|
||||
|
||||
@@ -26,6 +26,7 @@ export const RESOURCES = [
|
||||
"session", // active sessions, lookup
|
||||
"event", // the signed ledger feed + void
|
||||
"report", // events feed, occupancy, future reports
|
||||
"log", // application/diagnostic logs (app_logs) — view + retention
|
||||
] as const;
|
||||
export type Resource = (typeof RESOURCES)[number];
|
||||
|
||||
@@ -51,6 +52,7 @@ export const PERMISSIONS: readonly Permission[] = [
|
||||
"session:read",
|
||||
"event:read", "event:void",
|
||||
"report:read",
|
||||
"log:read",
|
||||
] as const;
|
||||
|
||||
/** The protected built-in role: non-deletable, non-editable, always = ALL
|
||||
@@ -150,6 +152,11 @@ export interface LedgerPayload {
|
||||
/** Interpolation values for `reasonCode`'s message template (counts, ids, ratios).
|
||||
* Signed alongside the code so the rendered sentence is reproducible. */
|
||||
readonly reasonParams?: Record<string, string | number>;
|
||||
/** subscription entry/exit: which credential the subscriber presented — `"qr"`
|
||||
* (QR code), `"card"` (RFID/NFC card or chip), or `"plate"` (bound plate / LPR).
|
||||
* Signed so the activity log can show HOW a subscriber entered/left (e.g. "via QR"),
|
||||
* and so a lost-card investigation can trace which credential was used. */
|
||||
readonly via?: "card" | "qr" | "plate";
|
||||
/** plate/vehicle from the vision service (advisory). */
|
||||
readonly plate?: string;
|
||||
readonly plateConfidence?: number;
|
||||
@@ -260,6 +267,52 @@ export function reasonPayload(
|
||||
/** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */
|
||||
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
|
||||
|
||||
/**
|
||||
* Application/diagnostic logs — a THIRD unsigned, prunable stream (app_logs), distinct
|
||||
* from the signed ledger and from device telemetry. Backend warn+ and frontend errors
|
||||
* land here so a booth problem is queryable in one place. See
|
||||
* wiki/concepts/app-logs.md, decisions/event-streams-split.md.
|
||||
*/
|
||||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
||||
export type LogSource = "frontend" | "backend";
|
||||
|
||||
/** A persisted log record (the read shape returned by GET /api/logs). */
|
||||
export interface AppLogRecord {
|
||||
readonly id: string;
|
||||
readonly level: LogLevel;
|
||||
readonly source: LogSource;
|
||||
readonly message: string;
|
||||
readonly context: Record<string, unknown> | null;
|
||||
readonly httpStatus: number | null;
|
||||
readonly path: string | null;
|
||||
readonly stack: string | null;
|
||||
readonly userId: string | null;
|
||||
readonly userAgent: string | null;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
/** One log entry POSTed by the frontend to /api/logs (server stamps id/userId/time). */
|
||||
export interface ClientLogInput {
|
||||
readonly level: LogLevel;
|
||||
readonly message: string;
|
||||
readonly context?: Record<string, unknown> | null;
|
||||
readonly httpStatus?: number | null;
|
||||
readonly path?: string | null;
|
||||
readonly stack?: string | null;
|
||||
/** Client-side capture time (ISO). The server records its own receive time too. */
|
||||
readonly at?: string;
|
||||
}
|
||||
|
||||
/** The numeric ordering of levels (pino-compatible), for threshold comparisons. */
|
||||
export const LOG_LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
trace: 10,
|
||||
debug: 20,
|
||||
info: 30,
|
||||
warn: 40,
|
||||
error: 50,
|
||||
fatal: 60,
|
||||
};
|
||||
|
||||
/**
|
||||
* The composable rate card stored in a tariff_version.structure.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, observability, diagnostics, logging, frontend, backend]
|
||||
sources: []
|
||||
updated: 2026-06-19
|
||||
status: open
|
||||
---
|
||||
|
||||
# Application logs (diagnostics) — the third stream
|
||||
|
||||
A **third data stream**, deliberately distinct from the two in [[event-streams-split]]:
|
||||
|
||||
| Stream | Table | Signed? | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| Business ledger | `ledger_events` | ✅ ATECC608 | money/accountability ([[append-only-event-chain]]) |
|
||||
| Device telemetry | `device_events` | ❌ | hardware chatter ([[device-events]]) |
|
||||
| **App logs** | **`app_logs`** | ❌ | **operational/diagnostic logs** (this page) |
|
||||
|
||||
App logs answer *"why did the booth misbehave?"* — a question neither of the other streams should
|
||||
absorb (logs are neither business facts nor hardware telemetry). On an **offline appliance** there's
|
||||
no Sentry/Datadog to ship to, so the host **is** the log store: backend warnings/errors AND frontend
|
||||
errors land in one queryable table, viewable at the booth. Built 2026-06-19.
|
||||
|
||||
## What's captured
|
||||
|
||||
- **Backend `warn` / `error` / `fatal`** — a pino stream tees these into `app_logs` (and still writes
|
||||
them to stdout, unchanged). `info`/`debug`/`trace` stay **stdout-only** — they'd bloat the DB. So
|
||||
every `app.log.warn/error(...)` already in the codebase is now persisted with **no call-site
|
||||
change**.
|
||||
- **Frontend errors** (always): every **failed API request** (`apiFetch`'s non-OK path → method,
|
||||
path, status, server error body — except `401`, which is normal pre-login churn), every **uncaught
|
||||
error** (`window.onerror`), every **unhandled promise rejection**, and a top-level **React
|
||||
ErrorBoundary** (a render crash is reported as `fatal` instead of a white screen).
|
||||
- **`console.warn` / `console.error`** — only forwarded when the **client log level is `debug`/`trace`**
|
||||
(off by default; they're noisy with third-party chatter). The high-signal sources above are always
|
||||
on. Toggle via `VITE_LOG_LEVEL` / `setClientLogLevel()`.
|
||||
|
||||
## Shape
|
||||
|
||||
`app_logs`: `level` (pino names), `source` (`frontend`|`backend`), `message`, `context` (one JSON
|
||||
column — the failed request, error name, component stack, anything), plus pulled-out `httpStatus` /
|
||||
`path` for cheap filtering, `stack`, `userId`, `userAgent`, `createdAt`. Indexed on `created_at` +
|
||||
`level`. Shared types: `AppLogRecord` / `ClientLogInput` / `LogLevel` in `@parking/shared`.
|
||||
|
||||
## The API + the access split
|
||||
|
||||
- **`POST /api/logs`** — the frontend ships errors here. **Any signed-in user** may write (it's their
|
||||
own browser's diagnostics) — `requireAuth`, not a permission. CSRF still applies (it's a mutation).
|
||||
Accepts one entry or a `{ entries: [...] }` batch (capped at 50). **Deliberately never 4xx's on a
|
||||
malformed entry** — a client erroring *while reporting an error* must not get a second error.
|
||||
- **`GET /api/logs`** — read the store (level/source/since filters), gated by the **new `log:read`
|
||||
permission** (a new `log` resource in the dynamic [[local-jwt-auth|RBAC]] grid). Admin holds it;
|
||||
it's grantable to a diagnostic role. *Verified: a cashier without `log:read` gets 403 on GET but
|
||||
204 on POST — the intended asymmetry.*
|
||||
|
||||
## Reliability invariants (a logger must never make things worse)
|
||||
|
||||
- **No infinite loop.** The frontend collector never logs the `/api/logs` request itself, and flushes
|
||||
via **raw `fetch`/`sendBeacon`**, not `apiFetch` (so a flush failure can't recurse into a new log).
|
||||
The backend `LogService` has a **reentrancy guard** — persisting a log can't emit a persisted log.
|
||||
- **Best-effort, never fatal.** Every write is wrapped; a DB/logging failure is swallowed (it can't be
|
||||
logged — that's the recursion we guard). Diagnostics must never break the path they observe.
|
||||
- **Bounded.** Frontend queue capped (drops oldest); message/stack/context clamped per row;
|
||||
ingest batch capped.
|
||||
|
||||
## Retention (offline appliance ⇒ must be bounded)
|
||||
|
||||
Pruned by **age AND a row cap** (a burst could blow past an age-only window): delete older than
|
||||
`LOG_RETENTION_DAYS` (default 30) **and** keep only the newest `LOG_RETENTION_MAX_ROWS` (default
|
||||
50 000). Runs **hourly** (unref'd timer) + once at startup. Both env-configurable. Same "prunable,
|
||||
not precious" durability class as `device_events` — the opposite of the append-only ledger.
|
||||
|
||||
## The booth viewer
|
||||
|
||||
A **Logs screen** under Setup (`/setup/logs`, gated by `log:read`, sq+en) — filter by
|
||||
level/source/since, newest first, each row expands to the structured `context` + stack. Read-only
|
||||
(logs are evidence, never edited). Polls every 15 s (no WS — diagnostics aren't latency-critical).
|
||||
Sits alongside the other admin tabs in [[booth-console]].
|
||||
|
||||
## As-built (2026-06-19)
|
||||
|
||||
- `packages/db`: `app_logs` table + migration `0009_app_logs.sql` (+ journal idx 9; seeds admin
|
||||
`log:read`). Applied to the live `apps/server/parking.sqlite`.
|
||||
- `@parking/shared`: `log` resource + `log:read` permission; `LogLevel`/`LogSource`/`AppLogRecord`/
|
||||
`ClientLogInput`/`LOG_LEVEL_ORDER`.
|
||||
- `apps/server`: `log-service.ts` (`LogService` + `pinoDbStream`), `routes/logs.ts`, wired in
|
||||
`server.ts` (DB built before Fastify so the pino stream has the sink; prune timer).
|
||||
- `apps/web`: `lib/logger.ts` (collector + global handlers), `lib/ErrorBoundary.tsx`, `apiFetch` hook,
|
||||
`LogsViewer.tsx` + route/nav, i18n.
|
||||
|
||||
## Open
|
||||
|
||||
- **No automated test** (the standing harness gap) — though the ingest/read/gate path was verified by
|
||||
in-process `app.inject` smoke (login → POST 204 → GET 200 with the record; non-admin 403/204 split).
|
||||
- **Server API error strings stay English** — unchanged here; this is about *persisting* logs, not
|
||||
localizing them. The localized-ledger-reason pattern ([[i18n]]) is the template if log *display*
|
||||
ever needs translation (currently the message is whatever the thrower wrote).
|
||||
- **Surfacing critical logs live** — a `fatal`/`error` count badge on the booth footer over the
|
||||
existing `/api/ws` could flag problems without opening the viewer. Deferred.
|
||||
- **Correlation id** — no request-id threads a frontend failed-request log to its backend log yet;
|
||||
add a `x-request-id` echo if cross-stream correlation is wanted.
|
||||
@@ -90,6 +90,18 @@ what happened." Now every event is **self-describing and clickable**:
|
||||
operator can tell "no camera" from "camera failed". (Surfaced a real incident: a subscriber's entry
|
||||
snapshot failed `EHOSTUNREACH` while the exit one succeeded — by design a snapshot is *evidence, not
|
||||
a gate*, so the open proceeded and only the image was missing.)
|
||||
- **Subscriber access medium (`via`).** A subscription entry/exit row now shows HOW the subscriber was
|
||||
identified — **QR code / RFID card·chip / plate** — as a cyan chip in the ticker and a labelled
|
||||
"Entry medium" row in the detail modal. The flow already signed `via` (`"qr"|"card"|"plate"`) into
|
||||
the [[subscription]] entry/exit payload; this just surfaces it (a lost-card investigation can now see
|
||||
which credential opened a barrier). Rides the existing localized-display pattern, not a new signed
|
||||
field.
|
||||
- **Refused entry/exit now carry a snapshot too (2026-06-19).** Previously only an *accepted* open
|
||||
captured an image. Now refusal/hold anomalies fire the directional camera as well (the photo of a
|
||||
turned-away car is exactly the evidence an operator/auditor wants) — so the detail modal's snapshot
|
||||
strip is populated for "lot full", unpaid-exit, no-session, and refused-subscription events. See
|
||||
[[entry-exit-points]] for the coverage list and the synthetic `REFUSED-…` key used when a refused
|
||||
entry has no ticket id.
|
||||
|
||||
## The shift control (header) + the booth gate
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ diagnostics, and live booth status — **not** anti-fraud.
|
||||
- **Device-keyed** — references the `devices` instance (raw device provenance). No `lane`
|
||||
(pool-of-spaces model — see [[entry-exit-points]]).
|
||||
|
||||
> **Not to be confused with [[app-logs]].** `device_events` is **hardware telemetry** (a relay fired,
|
||||
> a camera failed). Diagnostic/application logs (a failed API request, an uncaught frontend error,
|
||||
> a backend warning) are a **separate third stream** in `app_logs` — don't route app errors here, nor
|
||||
> hardware telemetry there. Both are unsigned + prunable; the distinction is *what produced it*.
|
||||
|
||||
## The boundary that matters
|
||||
|
||||
A device event is *evidence the host saw something happen*; it does **not** by itself authorize or
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, entry, anti-fraud, safety, devices]
|
||||
sources: []
|
||||
updated: 2026-06-19
|
||||
status: open
|
||||
---
|
||||
|
||||
# One car = one ticket (entry anti-double-press)
|
||||
|
||||
A transient [[parking-session|entry]] is a button press → print a ticket → sign a `vehicle_entry`
|
||||
→ open the barrier. **Nothing stopped a driver pressing the button repeatedly** and minting a fresh
|
||||
ticket each time — a real flaw found 2026-06-19. The damage is threefold:
|
||||
|
||||
- **Ticket spam.** One car walks away with a fistful of tickets.
|
||||
- **Occupancy corruption.** Each press signs a `vehicle_entry`, so [[capacity-occupancy|occupancy]]
|
||||
(a fold over open sessions) counts one car as many — the lot reads "full" with empty spaces.
|
||||
- **Ticket-shopping at exit.** With several open sessions for the same car, the driver pays the
|
||||
cheapest and exits on it; the rest linger. A direct [[threat-model|booth/customer]] abuse.
|
||||
|
||||
The old `#inFlight` guard only blocked *overlapping* presses (it released in `finally`), so
|
||||
press → print → press again issued a second ticket immediately. That is not enough.
|
||||
|
||||
## The fix is PER-RELAY CONFIG, chosen by available barrier feedback
|
||||
|
||||
The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exit-points]]), because
|
||||
whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes:
|
||||
|
||||
### PRESENCE mode (preferred — when a vehicle loop is wired)
|
||||
`relays[].presenceInput` = the 1-based input terminal of an **induction loop / barrier presence
|
||||
signal** on the same [[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its
|
||||
relays, and loops are already in the [[bom]]). The rule makes one-car-one-ticket **physical**:
|
||||
|
||||
- A press prints **only while a car is present** on the loop.
|
||||
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
|
||||
(the car drove through = it entered) **and a new car re-occupies** it.
|
||||
|
||||
So mashing the button while sitting on the loop does nothing; a *new* car must physically arrive
|
||||
before another ticket can issue. The flow observes the loop's input edges (both directions) to track
|
||||
`present` + `armed` per relay. This is how real lanes behave.
|
||||
|
||||
### COOLDOWN mode (fallback — no barrier feedback)
|
||||
When no loop is wired, `relays[].entryCooldownSec` suppresses repeat presses on that relay for N
|
||||
seconds after a ticket (default unset = no guard; a sensible value is ~10–12 s — long enough for the
|
||||
car to pull through, short enough not to block the next legitimate car). It is a **timer, a
|
||||
mitigation, not a guarantee** — a determined abuser can wait it out. Use it only where presence
|
||||
feedback isn't available; prefer wiring a loop.
|
||||
|
||||
The two can coexist (presence first, cooldown as a backstop), but presence is authoritative when set.
|
||||
|
||||
## A suppressed press is a NO-OP, not an anomaly
|
||||
|
||||
A blocked/repeat press is recorded as **unsigned [[device-events|telemetry]]** (a `device_events`
|
||||
`kind:"input"` row with `entrySuppressed:true` + the reason), **not** a signed ledger anomaly. It
|
||||
isn't fraud — it's the system correctly refusing to double-issue — so it stays out of the immutable
|
||||
chain and off the red activity-log feed. (Operator's call, 2026-06-19.) The press is still auditable
|
||||
in telemetry if ever needed.
|
||||
|
||||
## Invariants preserved
|
||||
|
||||
- **Fail-closed entry is untouched.** A suppressed press simply does nothing; the printer-down HOLD
|
||||
path ([[append-only-event-chain]]) and the [[fail-state-safety]] rules are unchanged.
|
||||
- **The barrier is still intent-only.** No timed close; presence is only a *gate on ticketing*, not
|
||||
a barrier-close trigger ([[barrier-not-a-door]]).
|
||||
- **State is in-memory + rebuildable.** The per-relay `armed/present` map is runtime state on the
|
||||
host (single-writer); it is derived from live input edges, never the source of truth. A restart
|
||||
starts armed (the first press after a restart works), which is the safe default.
|
||||
|
||||
## As-built (2026-06-19)
|
||||
|
||||
- `RelaySpec` gains `presenceInput?` + `entryCooldownSec?` (`device-resolve.ts`); `relayForButton`
|
||||
carries them onto the `ResolvedRelay`, and a new `relayForPresence()` resolves a loop-input edge to
|
||||
the entry relay it gates.
|
||||
- `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge`
|
||||
tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the
|
||||
telemetry. The guard disarms + stamps the cooldown on **print success** (not on open).
|
||||
- [[first-run-setup|SetupWizard]] relay editor: entry/both relays expose a **Presence loop
|
||||
(terminal)** field and, when no loop is set, a **Cooldown after ticket (s)** field (localized
|
||||
sq+en — see [[i18n]]).
|
||||
|
||||
## Open
|
||||
|
||||
- **No automated test yet** (the standing harness gap) — verify on hardware: with a loop, a held
|
||||
button issues one ticket; after the car clears the loop a new car gets a fresh one. Without a loop,
|
||||
a cooldown blocks the repeat and the suppressed press lands in telemetry.
|
||||
- **Exit side:** the symmetric concern (re-reading a ticket at exit) is already handled differently —
|
||||
exit validates against an open session, so a second read finds the session closed (no double-exit).
|
||||
No presence gate needed there today.
|
||||
- **Loop as a safety/anti-tailgate signal** is a larger future use of the same input (free-exit
|
||||
detection is noted in [[bom]]); this change uses it only to gate ticketing.
|
||||
@@ -30,6 +30,9 @@ config: {
|
||||
- `direction`: `entry` | `exit` | `both` (`both` = one barrier/relay serving in and out).
|
||||
- `button`: the **input terminal** the transient **entry button** is wired to. Only entry/both
|
||||
relays have one. Absent = no button at that barrier (subscriber/reader-driven only).
|
||||
- `presenceInput` / `entryCooldownSec`: the **one-car-one-ticket** guard for the entry button —
|
||||
`presenceInput` is the input terminal of a vehicle-presence loop (physical guard), or
|
||||
`entryCooldownSec` a fallback timer when there's no barrier feedback. See [[entry-double-press]].
|
||||
|
||||
The four real layouts all fall out of this:
|
||||
|
||||
@@ -103,6 +106,18 @@ backed-up DB, nothing scattered on disk), in its own table so hot telemetry scan
|
||||
bytes and images prune independently. Linked to the signed `vehicle_entry/exit` by `identity`.
|
||||
Served read-only via `GET /api/snapshots/:id`. **Retention is unresolved** — see [[open-questions]].
|
||||
|
||||
### Refused entry/exit ALSO snapshots (2026-06-19)
|
||||
A snapshot is evidence of **who was at the barrier** — which matters *most* when the barrier is
|
||||
**refused** (a turned-away car is a fraud/dispute signal: "lot full" denial, an unpaid exit attempt,
|
||||
a no-session ticket, an out-of-window subscription). Originally only the OPEN paths captured; now
|
||||
**every refusal/hold anomaly fires the directional camera too**, keyed to the same `identity` the
|
||||
anomaly carries so the [[booth-console|activity-log]] evidence strip finds it. Coverage: entry
|
||||
refused-full / held-no-ticket (a refused entry has no ticket id → mint a synthetic `REFUSED-…` ref
|
||||
to key the anomaly + photo together), exit refused closed/no-session/unpaid/grace-expired (booth
|
||||
*and* reader paths), and a refused [[subscription]] (the lane the reader sits at picks the camera).
|
||||
Same fire-and-forget contract — a refusal is never delayed by a camera. Failed captures still surface
|
||||
as "⚠ camera unreachable" tiles (see [[booth-console]]).
|
||||
|
||||
## Related
|
||||
|
||||
[[entry-exit-readers]] · [[device-events]] · [[parking-session]] · [[anti-passback]] ·
|
||||
|
||||
@@ -50,8 +50,16 @@ A raw button press is **telemetry** → `device_events`. The entry flow then min
|
||||
input-push handler to emit `device_events` (+ the entry flow signs `vehicle_entry`).
|
||||
- `ParkingEventType` in `packages/shared` splits into ledger types vs. a device-event type set.
|
||||
|
||||
## A third stream followed (2026-06-19)
|
||||
|
||||
The same separation logic produced a **third** stream: **`app_logs`** — operational/diagnostic logs
|
||||
(backend warn+ via a pino sink, plus frontend errors). They're neither business facts (ledger) nor
|
||||
hardware telemetry (device_events), so they get their own unsigned, prunable table. See
|
||||
[[app-logs]]. The principle generalizes: *one stream per durability/meaning class*.
|
||||
|
||||
## Open
|
||||
|
||||
- `device_events` retention/rotation policy.
|
||||
- `device_events` retention/rotation policy. (Resolved for `app_logs`: age + row cap — see
|
||||
[[app-logs]]; the same policy is a candidate for `device_events`.)
|
||||
- Which device facts (if any) are witness-grade enough to *also* warrant a signed ledger entry
|
||||
(e.g. `barrier_open_observed` from a loop sensor) — see [[append-only-event-chain]] witness gap.
|
||||
+3
-1
@@ -7,7 +7,7 @@ updated: 2026-06-19
|
||||
# Index
|
||||
|
||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
||||
Counts: 4 sources · 19 entities · 42 concepts · 5 decision records.
|
||||
Counts: 4 sources · 19 entities · 44 concepts · 5 decision records.
|
||||
|
||||
## Overview & navigation
|
||||
- [[overview]] — the top-level synthesis and entry point.
|
||||
@@ -75,6 +75,7 @@ Counts: 4 sources · 19 entities · 42 concepts · 5 decision records.
|
||||
- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay).
|
||||
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
|
||||
- [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots.
|
||||
- [[entry-double-press]] — one car = one ticket: per-relay presence-loop gate (preferred) or cooldown fallback; suppressed press = telemetry.
|
||||
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
|
||||
|
||||
## Concepts — business domain
|
||||
@@ -93,6 +94,7 @@ Counts: 4 sources · 19 entities · 42 concepts · 5 decision records.
|
||||
- [[ticket-encoding]] — transient ticket id (11-digit numeric + Luhn) as Code128; printed at entry, scanned at pay station + exit; barcode geometry must fit paper width (KP-300H overflow); plate-as-ticket alt.
|
||||
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
|
||||
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
|
||||
- [[app-logs]] — the third stream: diagnostic logs (backend warn+ pino sink + frontend errors) → app_logs; log:read viewer; pruned by age+row cap.
|
||||
- [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.)
|
||||
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness).
|
||||
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
|
||||
|
||||
+12
@@ -892,3 +892,15 @@ Dates were raw ISO on paper and time-only in the UI (a 2-day-old session showed
|
||||
## [2026-06-19] fix | KP-300H barcode line-overflow — ticket id 13→11 digits
|
||||
|
||||
The Cashino KP-300H entry dispenser printed entry tickets as RASTER GARBAGE (solid black bars/banding) while the Rongta printed the IDENTICAL byte stream fine. Diagnosed on hardware: plain-text-only prints were clean → isolated to the `GS k` Code128 barcode. ROOT CAUSE = barcode line-overflow, not corruption: a Code128-B symbol is (11·chars+35)·moduleWidth dots; the old 13-digit id at module width 3 = ~534 dots OVERRAN the KP-300H's 72mm line (512 usable dots @ 203 dpi). The Rongta runs 80mm (576 dots) and had just enough room — why only the Cashino failed. FIX: shorten the ticket id 13→11 digits (10 random + Luhn) → ~468 dots, fits 72mm; scanned the full value at the exit reader (verified). Length is driven by GUESS-RESISTANCE not volume (10^10 space, ~1-in-10^7 to hit a live open ticket vs the booth-operator threat); chose 11 over the requested 9 (10^8 → ~1-in-10^5, too weak). validateTicketCode made length-agnostic (\d{10,14}+Luhn) so legacy 13-digit tickets still validate. NB: module width must stay 3 — a width-2 test scanned but returned TRUNCATED values (partial reads logged as exit.refused.noSession anomalies). Also fixed a separate latent transport bug in sendRaw: write-then-destroy could RST mid-stream (the write callback ≠ peer-flushed) and truncate a job; now end(payload)+FIN, resolve on socket `close`, timeout-after-write = success. NOT the cause of the garbage but a real risk. Committed bbf61c4. Updated [[ticket-encoding]], [[rongta-printer]].
|
||||
|
||||
## [2026-06-19] feat | Snapshots on refused entry/exit + subscriber access medium in the activity log
|
||||
|
||||
Two booth-evidence gaps closed. (1) **Refused entry/exit now snapshot.** Originally only the OPEN paths fired the directional camera; refusal/hold anomalies didn't — yet a turned-away car is exactly the evidence an operator/auditor wants (fraud/dispute signal). Added `#fireSnapshot` to every refusal: entry refused-full + held-no-ticket (a refused entry has no ticket id, so mint a synthetic `REFUSED-…` ref to key the anomaly + photo together), exit refused closed/no-session/unpaid/grace-expired (BOTH booth `exitForBooth` and reader `#runExit` paths), and refused [[subscription]] (the lane the reader sits at — `resolved.direction`, "both"→entry — picks the camera). Same fire-and-forget contract: a refusal is never delayed/blocked by a camera; failed captures still surface as "⚠ camera unreachable" tiles. (2) **Subscriber access medium (`via`) surfaced.** The subscription flow already SIGNED `via` (`"qr"|"card"|"plate"`) into the entry/exit payload but the activity log never showed it. Added it as a typed `LedgerPayload.via` field, a cyan chip in the ticker, and an "Entry medium / Mënyra e hyrjes" row in the detail modal (QR code / RFID card·chip / plate, localized sq+en) — a lost-card investigation can now see which credential opened a barrier. Display-only, no re-signing. Refused-subscription anomalies also now carry `via`. Build+lint green. Updated [[entry-exit-points]], [[booth-console]].
|
||||
|
||||
## [2026-06-19] feat | One car = one ticket (entry anti-double-press) + refusal snapshots + subscriber via
|
||||
|
||||
FLAW found: the entry button could be pressed without limit — each press minted a fresh ticket + signed vehicle_entry, corrupting occupancy (one car counts as many) and letting a transient SHOP the cheapest ticket at exit. The old `#inFlight` guard only blocked OVERLAPPING presses (released in finally). FIX is per-relay config (`config.relays[]`), mode chosen by available barrier feedback: (1) PRESENCE (preferred) — `presenceInput` ties ticketing to a vehicle loop on a Dingtian input; a press prints only with a car present, and NO second ticket until the loop CLEARS (car drove in) and a new car re-occupies it → physical one-car-one-ticket; (2) COOLDOWN (fallback, no feedback) — `entryCooldownSec` suppresses repeat presses for N seconds (a timer, mitigation not guarantee). New `relayForPresence()` resolves a loop edge to its entry relay; `EntryFlow` keeps a per-relay `#guard` map (present/armed), disarms on PRINT success, re-arms on loop clear. A suppressed press = UNSIGNED device_events telemetry (entrySuppressed:true), NOT a signed anomaly (operator's call — it's a correct no-op, not fraud). SetupWizard relay editor exposes Presence-loop + Cooldown fields (sq+en). Fail-closed entry + barrier-is-not-a-door invariants untouched; guard state is in-memory/rebuildable, starts armed after restart (safe default). New page [[entry-double-press]]; updated [[entry-exit-points]], index. Build+lint green. (Bundled with this session's earlier refusal-snapshots + subscriber-`via` work.)
|
||||
|
||||
## [2026-06-19] feat | Application logs — backend pino DB sink + frontend error collection (app_logs)
|
||||
|
||||
Added a THIRD data stream (`app_logs`) alongside the signed ledger and device telemetry — operational/diagnostic logs, since an OFFLINE appliance has no Sentry to ship to. BACKEND: a pino stream tees warn/error/fatal into app_logs (info/debug stay stdout-only — no bloat) with NO call-site change; the DB is now built BEFORE Fastify so the logger stream has its sink. FRONTEND (lib/logger.ts): always ships failed API requests (apiFetch non-OK path, minus 401 pre-login churn), window.onerror, unhandledrejection, and a top-level React ErrorBoundary (render crash → fatal, not a white screen); console.warn/error forwarded ONLY at client debug/trace level (noisy otherwise). Batched/throttled POST, flush via raw fetch + sendBeacon on pagehide. Reliability invariants: never log the /api/logs call itself (loop guard), LogService reentrancy guard, all writes best-effort/swallowed, bounded queue + clamped rows. API: POST /api/logs (any signed-in user, CSRF, tolerant — never 4xx on a bad entry) + GET /api/logs gated by a NEW `log:read` permission (new `log` resource in the RBAC grid; admin holds it). Retention: pruned by age (LOG_RETENTION_DAYS=30) AND row cap (MAX_ROWS=50k), hourly + at startup. UI: a Logs screen under /setup (filter level/source/since, expand to context+stack, 15s poll), sq+en. DB migration 0009_app_logs (+journal idx 9, seeds admin log:read) applied to the live apps/server DB. 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 (the intended split). Build+lint green. New page [[app-logs]]; updated [[event-streams-split]], [[device-events]], index.
|
||||
|
||||
Reference in New Issue
Block a user