feat: explainable activity log — reasons, subscriber names, snapshot gaps
The live activity feed flagged anomalies with no explanation and showed opaque session keys. Make events self-describing and clickable. - Clickable feed rows → read-only event-detail modal: humanized fields, entry/exit snapshots, and signed-chain provenance collapsed behind an audit disclosure (operator sees the story, auditor expands for crypto). - Localized reason codes (backend i18n): the signed ledger now carries a stable REASON_CODE + params (+ English fallback) instead of free-text English. The UI translates via reason.<code> catalogs in sq/en, so an Albanian operator reads Albanian — from the same immutable event. Adding a language is a catalog change, no re-signing. (@parking/shared REASON_CODES, reasonPayload; entry/exit/subscription flows emit codes.) - Subscriber-name resolution: a SUBSESS-… occurrence now shows the subscription holder's name (fallback "Abonent"/"Subscriber"). Resolved read-time server-side (events API + WS push) as a non-signed subscriberLabel; cached with invalidation on subscription edit/delete. - Failed-snapshot visibility: a camera that was attempted but unreachable now shows a "⚠ camera unreachable" tile instead of a silent gap. The snapshots API returns failures[] from telemetry, filtered so a recovered capture shows no stale warning. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, eq, snapshots, type Db } from "@parking/db";
|
||||
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||
import { requirePermission } from "../auth.js";
|
||||
|
||||
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
|
||||
@@ -12,11 +12,15 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
|
||||
const guard = requirePermission("session:read");
|
||||
|
||||
// Snapshot metadata for one session/credential identity (NOT the bytes), newest
|
||||
// first — lets the UI show "entry/exit image" links beside an event.
|
||||
// first — lets the UI show "entry/exit image" links beside an event. We also return
|
||||
// FAILED capture attempts (from snapshot telemetry) so the operator can tell a
|
||||
// camera that was offline from a direction that simply has no camera — otherwise a
|
||||
// missing shot is a silent gap. See snapshot.ts (recordFailure).
|
||||
app.get<{ Params: { identity: string } }>(
|
||||
"/api/snapshots/by-identity/:identity",
|
||||
{ preHandler: guard },
|
||||
async (req) => {
|
||||
const identity = req.params.identity;
|
||||
const rows = db
|
||||
.select({
|
||||
id: snapshots.id,
|
||||
@@ -27,10 +31,44 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
|
||||
capturedAt: snapshots.capturedAt,
|
||||
})
|
||||
.from(snapshots)
|
||||
.where(eq(snapshots.identity, req.params.identity))
|
||||
.where(eq(snapshots.identity, identity))
|
||||
.orderBy(desc(snapshots.capturedAt))
|
||||
.all();
|
||||
return { snapshots: rows };
|
||||
|
||||
// Failed attempts: kind="snapshot" telemetry whose detail.identity matches and
|
||||
// detail.ok === false. There may be both a failure and (on a retry) a success
|
||||
// for the same direction; we keep only failures with NO successful shot in the
|
||||
// same direction, so a recovered capture doesn't show a stale warning.
|
||||
const haveDir = new Set<string | null>(rows.map((r) => r.direction));
|
||||
const telemetry = db
|
||||
.select({ detail: deviceEvents.detail, deviceId: deviceEvents.deviceId, occurredAt: deviceEvents.occurredAt })
|
||||
.from(deviceEvents)
|
||||
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "snapshot")))
|
||||
.orderBy(desc(deviceEvents.occurredAt))
|
||||
.all();
|
||||
const failures: {
|
||||
direction: "entry" | "exit" | null;
|
||||
deviceId: string;
|
||||
error: string;
|
||||
occurredAt: string;
|
||||
}[] = [];
|
||||
const seenFailDir = new Set<string>();
|
||||
for (const row of telemetry) {
|
||||
const d = (row.detail ?? {}) as { identity?: string; ok?: boolean; error?: string; direction?: string };
|
||||
if (d.identity !== identity || d.ok !== false) continue;
|
||||
const dir = d.direction === "entry" || d.direction === "exit" ? d.direction : null;
|
||||
const dirKey = dir ?? "both";
|
||||
if (haveDir.has(dir) || seenFailDir.has(dirKey)) continue; // a success exists, or already shown
|
||||
seenFailDir.add(dirKey);
|
||||
failures.push({
|
||||
direction: dir,
|
||||
deviceId: row.deviceId ?? "",
|
||||
error: d.error ?? "capture failed",
|
||||
occurredAt: row.occurredAt ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
return { snapshots: rows, failures };
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user