feat(cameras): live ONVIF event feed on camera detail page

Camera edit page gains a "Live Events" panel that auto-refreshes every
5s via htmx. Shows last 20 events for this camera from event_log:
topic, source type, timestamp, and raw payload JSON. Surfaces ALL
ONVIF topics including unknown ones — if a camera produces an event
type we haven't seen before, it shows up here immediately.

queryEvents gains camera_id + source_type filters. Route
GET /admin/cameras/:id/events returns an HTML fragment with the event
table rows.
This commit is contained in:
Mitchell R 2026-05-21 12:09:09 +02:00
parent 74a871cd9b
commit 9f382775a7
No known key found for this signature in database
2 changed files with 52 additions and 0 deletions

View file

@ -1434,6 +1434,38 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
}); });
// ---- Camera live event feed (htmx fragment, polled every 5s) ---------------
const formatTimeShort = (iso: string) => {
try { return new Date(iso).toLocaleString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", day: "2-digit", month: "short" }); }
catch { return iso; }
};
const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
app.get("/admin/cameras/:id/events", (event) => {
const id = Number(getRouterParam(event, "id"));
const { events } = deps.repo.queryEvents({
camera_id: id,
limit: 20,
});
if (events.length === 0) {
return htmlFragment(
`<div style="color:#999; font-size:0.85rem; padding:1rem 0">No events yet. ONVIF events appear here as the kiosk receives them.</div>`,
);
}
const rows = events.map((e) => {
let payload = "";
try { payload = JSON.stringify(e.payload, null, 1); } catch { payload = String(e.payload); }
return `<tr>
<td style="font-size:0.8rem; white-space:nowrap">${formatTimeShort(e.received_at)}</td>
<td><code style="font-size:0.75rem">${escapeHtml(e.topic)}</code></td>
<td style="font-size:0.75rem">${escapeHtml(e.source_type)}</td>
<td><pre style="margin:0; font-size:0.7rem; max-height:80px; overflow:auto; background:#fafafa; padding:2px 4px">${escapeHtml(payload)}</pre></td>
</tr>`;
}).join("");
return htmlFragment(
`<table><thead><tr><th>Time</th><th>Topic</th><th>Source</th><th>Payload</th></tr></thead><tbody>${rows}</tbody></table>`,
);
});
// ---- Kiosk edit/delete/labels --------------------------------------------- // ---- Kiosk edit/delete/labels ---------------------------------------------
app.get("/admin/kiosks/:id", (event) => { app.get("/admin/kiosks/:id", (event) => {

View file

@ -1329,6 +1329,26 @@ export function CameraEditPage(props: CameraEditProps) {
)} )}
</div> </div>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Live Events</h2>
<p style="color:#666; font-size:0.85rem; margin-bottom:0.75rem">
ONVIF events from kiosks subscribed to this camera. Auto-refreshes
every 5s. All topics shown motion, ANPR, line crossing, I/O, analytics, unknown.
</p>
<div
id={`camera-events-${String(cam.id)}`}
class="table-wrap"
{...{
"hx-get": `/admin/cameras/${cam.id}/events`,
"hx-trigger": "load, every 5s",
"hx-swap": "innerHTML",
}}
style="max-height:300px; overflow-y:auto"
>
<div style="color:#999; font-size:0.85rem; padding:1rem 0">Loading...</div>
</div>
</div>
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Kiosk Subscriptions</h2> <h2 style="margin:0 0 1rem; font-size:1.1rem">Kiosk Subscriptions</h2>
<p style="color:#666; font-size:0.85rem; margin-bottom:0.75rem"> <p style="color:#666; font-size:0.85rem; margin-bottom:0.75rem">