/** * Admin page routes — overview, cameras, kiosks, labels, etc. */ import { type H3, readBody, getRouterParam, getRequestHeader } from "h3"; import { htmlPage } from "./html-response.js"; import type { AdminDeps } from "./index.js"; import { confirmPairing } from "../../shared/pairing.js"; import { getCoordinator } from "../../shared/coordinator-registry.js"; import { OverviewPage, CamerasPage, CameraNewPage, CameraEditPage, CameraDiscoverPage, AuditLogPage, BackupPage, CameraDiscoverResultsPage, EntitiesPage, EntityNewPage, EntityEditPage, KiosksPage, KioskEditPage, LabelsPage, LayoutsPage, LayoutNewPage, LayoutEditPage, DisplaysPage, DisplayEditPage, SystemHealthPage, NoderedEmbedPage, renderCell, renderGrid, renderCameraLabels, renderKioskLabels, renderDisplayLayouts, renderDefaultLayoutSelect, } from "../../web-templates/admin-pages.js"; import { discover as onvifDiscover, getEventProperties as onvifGetEventProperties } from "../../shared/onvif.js"; import { generateBundle } from "../../shared/bundle.js"; import { captureSnapshot } from "../../shared/snapshot.js"; import { stripSecrets } from "../../shared/strip-secrets.js"; import { audit } from "../../shared/audit.js"; import { createBackup, restoreBackup } from "../../shared/backup.js"; import { pickKioskLanIp } from "../../shared/kiosk-lan.js"; interface DiscoverAddStream { profile_name: string; profile_token: string; source_token: string | null; encoding: string | null; width: number | null; height: number | null; framerate: number | null; stream_uri: string; snapshot_uri?: string | null; role: "main" | "sub" | "other"; } type FormValue = string | string[] | undefined; function htmlFragment(markup: unknown): Response { return new Response(String(markup), { headers: { "content-type": "text/html; charset=utf-8" }, }); } function jsonResponse(value: unknown, status: number = 200): Response { return new Response(JSON.stringify(stripSecrets(value)), { status, headers: { "content-type": "application/json" }, }); } function hostnameFromName(name: string): string { const slug = name .toLowerCase() .replace(/[^a-z0-9-]+/g, "-") .replace(/^-+|-+$/g, "") .replace(/-{2,}/g, "-") .slice(0, 63) .replace(/^-+|-+$/g, ""); return slug || "betterframe-kiosk"; } function isHtmxRequest(event: Parameters[0]): boolean { return getRequestHeader(event, "hx-request") === "true"; } function notifyKiosks(): void { try { getCoordinator().notifyBundleChanged(); } catch { /* ignore */ } } function sanitizeRtspUrl(raw: string): string { const match = raw.match(/^(rtsp:\/\/)([^@]+)@(.+)$/); if (!match) return raw; const [, scheme, userinfo, rest] = match; const colonIdx = userinfo!.indexOf(":"); if (colonIdx < 0) return raw; const user = encodeURIComponent(userinfo!.slice(0, colonIdx)); const pass = encodeURIComponent(userinfo!.slice(colonIdx + 1)); return `${scheme}${user}:${pass}@${rest}`; } async function uniqueCameraName(deps: AdminDeps, rawName: string): Promise { let name = rawName; if (await deps.repo.getCameraByName(name)) { let i = 2; while (await deps.repo.getCameraByName(`${rawName} (${String(i)})`)) i += 1; name = `${rawName} (${String(i)})`; } return name; } function rtspWithCredentials(raw: string, username: string, password: string): string { if (!username) return raw; try { const url = new URL(raw); if (url.protocol !== "rtsp:" || url.username) return raw; url.username = username; url.password = password; return url.toString(); } catch { return raw; } } function formValue(v: FormValue): string { return Array.isArray(v) ? (v[0] ?? "") : (v ?? ""); } function formValues(v: FormValue): string[] { if (Array.isArray(v)) return v; return v ? [v] : []; } function kioskOnvifSoapTransport(kioskId: number) { return async (url: string, action: string, body: string, timeoutMs: number): Promise => { if (!Number.isInteger(kioskId) || kioskId <= 0) { throw new Error("invalid kiosk selected for discovery"); } const response = await getCoordinator().requestKiosk<{ type?: string; request_id?: string; status?: number; body?: string; error?: string; }>(kioskId, { type: "onvif-soap-request", url, action, body, timeout_ms: timeoutMs, }, timeoutMs + 3000); if (response.error) throw new Error(response.error); const status = Number(response.status ?? 0); const text = response.body ?? ""; if (status < 200 || status >= 300) { throw new Error(`ONVIF ${action} via kiosk ${String(kioskId)} HTTP ${String(status)}: ${text.slice(0, 300)}`); } return text; }; } function parseDiscoveredStreams(raw: string): DiscoverAddStream[] { try { const parsed = JSON.parse(raw) as DiscoverAddStream[]; return Array.isArray(parsed) ? parsed : []; } catch { return []; } } async function importDiscoveredCamera( deps: AdminDeps, rawName: string, onvifHost: string, onvifPort: number, username: string, password: string, streams: DiscoverAddStream[], ): Promise { if (streams.length === 0) return null; const main = streams.find((s) => s.role === "main") ?? streams[0]!; const mainRtspUrl = rtspWithCredentials(main.stream_uri, username, password); const name = await uniqueCameraName(deps, rawName || "ONVIF camera"); const cam = await deps.repo.createCamera({ name, type: "onvif", rtsp_url: mainRtspUrl, onvif_host: onvifHost, onvif_port: onvifPort, onvif_username: username, onvif_password: password, } as any); for (const stream of streams) { const width = stream.width == null ? null : Number(stream.width); const height = stream.height == null ? null : Number(stream.height); const framerate = stream.framerate == null ? null : Number(stream.framerate); await deps.repo.createCameraStream({ camera_id: cam.id, role: stream.role === "main" || stream.role === "sub" ? stream.role : "other", name: stream.profile_name || stream.role, rtsp_uri: rtspWithCredentials(stream.stream_uri, username, password), profile_token: stream.profile_token || null, width: Number.isFinite(width) ? width : null, height: Number.isFinite(height) ? height : null, encoding: stream.encoding || null, framerate: Number.isFinite(framerate) ? framerate : null, is_discovered: true, }); } return cam.id; } function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { return aStart < bEnd && bStart < aEnd; } function cellsOverlap( a: { row: number; col: number; row_span: number; col_span: number }, b: { row: number; col: number; row_span: number; col_span: number }, ): boolean { return ( a.col < b.col + b.col_span && b.col < a.col + a.col_span && a.row < b.row + b.row_span && b.row < a.row + a.row_span ); } interface CellPos { id: number; row: number; col: number; row_span: number; col_span: number; } async function resolveOverlaps( deps: AdminDeps, layoutId: number, anchorId: number, pushAxis: "row" | "col", ): Promise { const all = await deps.repo.layoutCells(layoutId); const positions = new Map(); for (const c of all) { positions.set(c.id, { id: c.id, row: c.row, col: c.col, row_span: c.row_span, col_span: c.col_span }); } const maxIter = positions.size * positions.size; for (let iter = 0; iter < maxIter; iter++) { let moved = false; const anchor = positions.get(anchorId); if (!anchor) break; for (const [id, pos] of positions) { if (id === anchorId) continue; if (!cellsOverlap(anchor, pos)) continue; if (pushAxis === "col") { pos.col = anchor.col + anchor.col_span; } else { pos.row = anchor.row + anchor.row_span; } moved = true; } if (!moved) break; // Cascade: pushed cells may now overlap each other. // Sort by position on push axis so earlier cells push later ones. const sorted = [...positions.values()].sort((a, b) => pushAxis === "col" ? a.col - b.col || a.row - b.row : a.row - b.row || a.col - b.col, ); let cascaded = false; for (let i = 0; i < sorted.length; i++) { for (let j = i + 1; j < sorted.length; j++) { if (sorted[i]!.id === sorted[j]!.id) continue; if (!cellsOverlap(sorted[i]!, sorted[j]!)) continue; if (pushAxis === "col") { sorted[j]!.col = sorted[i]!.col + sorted[i]!.col_span; } else { sorted[j]!.row = sorted[i]!.row + sorted[i]!.row_span; } cascaded = true; } } if (!cascaded) break; } for (const pos of positions.values()) { const orig = all.find((c) => c.id === pos.id)!; if (orig.row !== pos.row || orig.col !== pos.col) { await deps.repo.updateLayoutCell(pos.id, { row: pos.row, col: pos.col }); } } } async function shiftCellsForExpansion( deps: AdminDeps, layoutId: number, cellId: number, direction: "left" | "right" | "above" | "bottom", ): Promise { const cell = await deps.repo.getLayoutCellById(cellId); if (!cell || cell.layout_id !== layoutId) return; if (direction === "right") { await deps.repo.updateLayoutCell(cell.id, { col_span: cell.col_span + 1 }); await resolveOverlaps(deps, layoutId, cell.id, "col"); } else if (direction === "bottom") { await deps.repo.updateLayoutCell(cell.id, { row_span: cell.row_span + 1 }); await resolveOverlaps(deps, layoutId, cell.id, "row"); } else if (direction === "left") { const newCol = Math.max(0, cell.col - 1); await deps.repo.updateLayoutCell(cell.id, { col: newCol, col_span: cell.col_span + 1 }); await resolveOverlaps(deps, layoutId, cell.id, "col"); } else if (direction === "above") { const newRow = Math.max(0, cell.row - 1); await deps.repo.updateLayoutCell(cell.id, { row: newRow, row_span: cell.row_span + 1 }); await resolveOverlaps(deps, layoutId, cell.id, "row"); } } async function shiftCellsForInsertion( deps: AdminDeps, layoutId: number, axis: "row" | "col", fromIndex: number, crossStart: number, crossEnd: number, ): Promise { for (const c of await deps.repo.layoutCells(layoutId)) { if (axis === "col") { if (c.col >= fromIndex && rangesOverlap(c.row, c.row + c.row_span, crossStart, crossEnd)) { await deps.repo.updateLayoutCell(c.id, { col: c.col + 1 }); } } else if (c.row >= fromIndex && rangesOverlap(c.col, c.col + c.col_span, crossStart, crossEnd)) { await deps.repo.updateLayoutCell(c.id, { row: c.row + 1 }); } } } export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- Overview ------------------------------------------------------------- app.get("/admin/", async (event) => { const user = event.context.user!; const cameras = await deps.repo.listCameras(); const kiosks = await deps.repo.listKiosks(); const layouts = await deps.repo.listDisplays(); // for count const events = await deps.repo.recentEvents(10); const onlineKiosks = kiosks.filter((k) => { if (!k.last_seen_at) return false; return Date.now() - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000; }); return htmlPage(OverviewPage({ user: user.username, cameraCount: cameras.length, kioskCount: kiosks.length, onlineKioskCount: onlineKiosks.length, layoutCount: layouts.length, events, })); }); // Redirect /admin to /admin/ app.get("/admin", async () => { return new Response(null, { status: 301, headers: { location: "/admin/" } }); }); // ---- Backup / restore ----------------------------------------------------- app.get("/admin/backup", async (event) => { const user = event.context.user!; return htmlPage(BackupPage({ user: user.username })); }); app.post("/admin/backup/download", async (event) => { const body = await readBody>(event); const pass = body?.["passphrase"] ?? ""; let res; try { res = createBackup(deps.dataDir, pass); } catch (err) { await audit(deps.repo, event as any, "backup.create", { result: "failed", metadata: { error: (err as Error).message }, }); return htmlPage(BackupPage({ user: event.context.user!.username, error: (err as Error).message })); } await audit(deps.repo, event as any, "backup.create", { metadata: { file_count: res.fileCount, size: res.blob.length }, }); return new Response(new Uint8Array(res.blob), { status: 200, headers: { "content-type": "application/octet-stream", "content-disposition": `attachment; filename="${res.filename}"`, "content-length": String(res.blob.length), }, }); }); app.post("/admin/backup/restore", async (event) => { const form = await event.req.formData(); const file = form.get("blob"); const pass = String(form.get("passphrase") ?? ""); if (!(file instanceof File) || !pass) { return htmlPage(BackupPage({ user: event.context.user!.username, error: "blob + passphrase required" })); } try { const buf = Buffer.from(await file.arrayBuffer()); const res = restoreBackup(deps.dataDir, pass, buf); await audit(deps.repo, event as any, "backup.restore", { metadata: { file_count: res.fileCount, files: res.files }, }); return htmlPage(BackupPage({ user: event.context.user!.username, success: `Restored ${String(res.fileCount)} files: ${res.files.join(", ")}. RESTART THE SERVER NOW for changes to take effect.`, })); } catch (err) { await audit(deps.repo, event as any, "backup.restore", { result: "failed", metadata: { error: (err as Error).message }, }); return htmlPage(BackupPage({ user: event.context.user!.username, error: (err as Error).message })); } }); // ---- Audit log ------------------------------------------------------------ app.get("/admin/audit", async (event) => { const user = event.context.user!; const url = new URL(event.req.url); const filterAction = url.searchParams.get("action")?.trim() || undefined; const filterActorType = url.searchParams.get("actor_type")?.trim() || undefined; const entries = await deps.repo.listAudit({ limit: 300, action_prefix: filterAction, actor_type: filterActorType as any || undefined, }); return htmlPage(AuditLogPage({ user: user.username, entries, filterAction, filterActorType })); }); // ---- System Health -------------------------------------------------------- app.get("/admin/health", async (event) => { const user = event.context.user!; const kiosks = await deps.repo.listKiosks(); const now = Date.now(); let clusterKey: string | undefined; try { const enc = await deps.repo.getSetupExtra("cluster_key_encrypted") as string | undefined; if (enc) clusterKey = deps.secrets.decryptString(enc, "cluster"); } catch { /* ignore */ } const rows = []; for (const k of kiosks) { const online = k.last_seen_at ? now - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000 : false; const displays = await deps.repo.listDisplaysForKiosk(k.id); let expectedBundleVersion: string | null = null; try { const b = await generateBundle(deps.repo, deps.secrets, k.id, clusterKey); expectedBundleVersion = b?.version ?? null; } catch { /* ignore */ } const bundleMismatch = expectedBundleVersion != null && k.last_bundle_version != null && k.last_bundle_version !== expectedBundleVersion; rows.push({ kiosk: k, online, bundleMismatch, expectedBundleVersion, displays, }); } return htmlPage(SystemHealthPage({ user: user.username, rows })); }); // ---- Cameras -------------------------------------------------------------- app.get("/admin/cameras", async (event) => { const user = event.context.user!; const cameras = await deps.repo.listCameras(); const streamCounts = new Map(); const activeKiosks = new Map(); // camera_id → count of kiosks rendering for (const cam of cameras) { streamCounts.set(cam.id, (await deps.repo.listCameraStreams(cam.id)).length); activeKiosks.set(cam.id, (await deps.repo.listKiosksRenderingCamera(cam.id)).length); } return htmlPage(CamerasPage({ user: user.username, cameras, streamCounts, activeKiosks })); }); app.get("/admin/cameras/new", async (event) => { const user = event.context.user!; return htmlPage(CameraNewPage({ user: user.username })); }); app.post("/admin/cameras/new", async (event) => { const user = event.context.user!; const body = await readBody>(event); const name = (body?.["name"] ?? "").trim(); const errors: string[] = []; if (!name || name.length > 128) { errors.push("Name required (max 128 chars)."); } else if (await deps.repo.getCameraByName(name)) { errors.push("Camera name already in use."); } const host = (body?.["rtsp_host"] ?? "").trim(); const port = (body?.["rtsp_port"] ?? "554").trim(); const path = (body?.["rtsp_path"] ?? "").trim(); const username = (body?.["rtsp_username"] ?? "").trim(); const pass = body?.["rtsp_password"] ?? ""; let rtspUrl: string | undefined; if (!host) { errors.push("RTSP host required."); } else { const userPart = username ? `${encodeURIComponent(username)}:${encodeURIComponent(pass)}@` : ""; const pathPart = path.startsWith("/") ? path : `/${path}`; rtspUrl = `rtsp://${userPart}${host}:${port}${pathPart}`; } if (errors.length > 0) { return htmlPage(CameraNewPage({ user: user.username, error: errors.join(" "), values: body, })); } const cam = await deps.repo.createCamera({ name, type: "rtsp", rtsp_url: rtspUrl ?? null, }); if (rtspUrl) { await deps.repo.createCameraStream({ camera_id: cam.id, role: "main", name: "Main", rtsp_uri: rtspUrl, }); } notifyKiosks(); deps.nodered.forward("camera.changed", { camera_id: cam.id, event: "created", source: "server" }); return new Response(null, { status: 302, headers: { location: "/admin/cameras" }, }); }); // ---- Camera ONVIF discovery ------------------------------------------------ app.get("/admin/cameras/discover", async (event) => { const user = event.context.user!; return htmlPage(CameraDiscoverPage({ user: user.username, kiosks: await deps.repo.listKiosks(), })); }); app.post("/admin/cameras/discover", async (event) => { const user = event.context.user!; const body = await readBody>(event); const host = (body?.["host"] ?? "").trim(); const port = parseInt(body?.["port"] ?? "80", 10) || 80; const username = (body?.["username"] ?? "").trim(); const password = body?.["password"] ?? ""; const runner = (body?.["discovery_runner"] ?? "server").trim(); if (!host) { return htmlPage(CameraDiscoverPage({ user: user.username, kiosks: await deps.repo.listKiosks(), error: "Host required.", values: body, })); } try { const soapTransport = runner.startsWith("kiosk:") ? kioskOnvifSoapTransport(Number(runner.slice("kiosk:".length))) : undefined; const cameras = await onvifDiscover({ host, port, username, password, soapTransport }); return htmlPage(CameraDiscoverResultsPage({ user: user.username, host, port, username, password, cameras, })); } catch (err) { return htmlPage(CameraDiscoverPage({ user: user.username, kiosks: await deps.repo.listKiosks(), error: `Discovery failed: ${(err as Error).message}`, values: body, })); } }); app.post("/admin/cameras/discover/add", async (event) => { const body = await readBody>(event); const onvifHost = formValue(body?.["host"]).trim(); const onvifPort = parseInt(formValue(body?.["port"]) || "80", 10) || 80; const username = formValue(body?.["username"]).trim(); const password = formValue(body?.["password"]); let imported = 0; const selected = formValues(body?.["selected"]); if (selected.length > 0) { for (const idx of selected) { const rawName = formValue(body?.[`camera_${idx}_name`]).trim() || "ONVIF camera"; const streams = parseDiscoveredStreams(formValue(body?.[`camera_${idx}_streams_json`])); if (streams.length === 0) continue; const camId = await importDiscoveredCamera(deps, rawName, onvifHost, onvifPort, username, password, streams); if (camId != null) { deps.nodered.forward("camera.changed", { camera_id: camId, event: "created", source: "server" }); } imported += 1; } } else { const rawName = formValue(body?.["name"]).trim() || "ONVIF camera"; const streams = parseDiscoveredStreams(formValue(body?.["streams_json"])); if (streams.length > 0) { const camId = await importDiscoveredCamera(deps, rawName, onvifHost, onvifPort, username, password, streams); if (camId != null) { deps.nodered.forward("camera.changed", { camera_id: camId, event: "created", source: "server" }); } imported += 1; } } if (imported === 0) { return new Response(null, { status: 302, headers: { location: "/admin/cameras/discover" } }); } notifyKiosks(); return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); }); // ---- Entities -------------------------------------------------------------- app.get("/admin/entities", async (event) => { const user = event.context.user!; syncDashboardsFromNodered(deps).catch(() => {}); return htmlPage(EntitiesPage({ user: user.username, entities: await deps.repo.listEntities(), })); }); app.get("/admin/entities/new", async (event) => { const user = event.context.user!; return htmlPage(EntityNewPage({ user: user.username, cameras: await deps.repo.listCameras(), })); }); app.post("/admin/entities/new", async (event) => { const user = event.context.user!; const body = await readBody>(event); const name = (body?.["name"] ?? "").trim(); const type = body?.["type"] as "camera" | "html" | "web" | undefined; const description = (body?.["description"] ?? "").trim() || null; const errors: string[] = []; if (!name || name.length > 128) { errors.push("Name required (max 128 chars)."); } else if (await deps.repo.getEntityByName(name)) { errors.push("Entity name already in use."); } if (type !== "camera" && type !== "html" && type !== "web") { errors.push("Select an entity type."); } let cameraId: number | null = null; let htmlContent: string | null = null; let webUrl: string | null = null; if (type === "camera") { cameraId = body?.["camera_id"] ? Number(body["camera_id"]) : null; if (!cameraId) errors.push("Pick a camera."); } else if (type === "html") { htmlContent = body?.["html_content"] ?? null; } else if (type === "web") { webUrl = (body?.["web_url"] ?? "").trim() || null; if (!webUrl) errors.push("URL required."); } if (errors.length > 0) { return htmlPage(EntityNewPage({ user: user.username, cameras: await deps.repo.listCameras(), error: errors.join(" "), values: body, })); } await deps.repo.createEntity({ name, type: type!, description, camera_id: cameraId, html_content: htmlContent, web_url: webUrl, }); notifyKiosks(); return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); }); app.get("/admin/entities/:id", async (event) => { const user = event.context.user!; const id = Number(getRouterParam(event, "id")); const ent = await deps.repo.getEntityById(id); if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); return htmlPage(EntityEditPage({ user: user.username, entity: ent, cameras: await deps.repo.listCameras(), })); }); app.post("/admin/entities/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const ent = await deps.repo.getEntityById(id); if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); const body = await readBody>(event); const patch: { name?: string; description?: string | null; camera_id?: number | null; html_content?: string | null; web_url?: string | null; } = { name: (body?.["name"] ?? ent.name).trim(), description: (body?.["description"] ?? "").trim() || null, }; if (ent.type === "camera") { patch.camera_id = body?.["camera_id"] ? Number(body["camera_id"]) : null; } else if (ent.type === "html") { patch.html_content = body?.["html_content"] ?? null; } else if (ent.type === "web") { patch.web_url = (body?.["web_url"] ?? "").trim() || null; } await deps.repo.updateEntity(id, patch); notifyKiosks(); return new Response(null, { status: 302, headers: { location: `/admin/entities/${String(id)}` } }); }); app.post("/admin/entities/:id/delete", async (event) => { const id = Number(getRouterParam(event, "id")); await deps.repo.deleteEntity(id); notifyKiosks(); return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); }); // Camera snapshot — prefer a kiosk already rendering this camera so we don't // double the RTSP load on the source. Fall back to server-direct only when // no kiosk currently has the camera in its active layout (or every kiosk // attempt times out). Used by the EntityEditPage "Test" preview. app.get("/admin/entities/:id/snapshot", async (event) => { const id = Number(getRouterParam(event, "id")); const ent = await deps.repo.getEntityById(id); if (!ent || ent.type !== "camera" || ent.camera_id == null) { return new Response("Not a camera entity", { status: 404 }); } const cameraId = ent.camera_id; // 1. Try kiosks that have this camera in ANY layout (bundle-level). // Even if the camera isn't on screen right now, the kiosk is on the // same LAN and can open a one-shot RTSP connection for the snapshot. // Only fall through to server-direct when NO kiosk has it at all. const candidates = await deps.repo.listKiosksWithCameraInBundle(cameraId); const STALE_MS = 2 * 60 * 1000; // kiosk silent > 2 min → don't bother const now = Date.now(); for (const k of candidates) { if (!k.local_port || !k.local_key) continue; if (k.last_seen_at && now - new Date(k.last_seen_at).getTime() > STALE_MS) continue; const ip = pickKioskLanIp(k); if (!ip) continue; const url = `http://${ip}:${String(k.local_port)}/local/snapshot/${String(cameraId)}?key=${encodeURIComponent(k.local_key)}`; try { const res = await fetch(url, { signal: AbortSignal.timeout(4000) }); if (res.ok) { const bytes = new Uint8Array(await res.arrayBuffer()); return new Response(bytes, { status: 200, headers: { "content-type": res.headers.get("content-type") ?? "image/jpeg", "cache-control": "no-store", "x-bf-snapshot-source": `kiosk:${String(k.id)}`, }, }); } } catch { // Network error / timeout — try next kiosk. } } // 2. Fall back to server-direct RTSP pull (ffmpeg/gst). const streams = await deps.repo.listCameraStreams(cameraId); const main = streams.find((s) => s.role === "main") ?? streams[0]; const cam = await deps.repo.getCameraById(cameraId); const rtsp = main?.rtsp_uri ?? cam?.rtsp_url ?? null; if (!rtsp) return new Response("No RTSP URL", { status: 404 }); const jpeg = await captureSnapshot(rtsp, { timeoutMs: 8000 }); if (!jpeg) { return new Response("Snapshot failed (camera unreachable or ffmpeg/gst missing)", { status: 502 }); } return new Response(jpeg, { status: 200, headers: { "content-type": "image/jpeg", "cache-control": "no-store", "x-bf-snapshot-source": "server", }, }); }); // ---- Kiosks --------------------------------------------------------------- app.get("/admin/kiosks", async (event) => { const user = event.context.user!; const kiosks = await deps.repo.listKiosks(); const pending = await deps.repo.listPendingPairingCodes(); return htmlPage(KiosksPage({ user: user.username, kiosks, pendingCodes: pending })); }); app.post("/admin/kiosks/pair", async (event) => { const body = await readBody>(event); const code = (body?.["code"] ?? "").trim().toUpperCase(); const nameOverride = (body?.["name_override"] ?? "").trim() || undefined; const labelsStr = (body?.["initial_labels"] ?? "").trim(); const initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined; const replaceIdRaw = (body?.["replace_kiosk_id"] ?? "").trim(); const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined; const force = body?.["force"] === "1"; try { const result = await confirmPairing(deps.repo, deps.auth, deps.secrets, { code, nameOverride, initialLabels, replaceKioskId, force, }); await audit(deps.repo, event as any, replaceKioskId ? "kiosk.replace" : "kiosk.pair", { resource_type: "kiosk", resource_id: result.kioskId, metadata: { name: result.kioskName, code, replaced: !!replaceKioskId }, }); } catch (err) { const user = event.context.user!; const kiosks = await deps.repo.listKiosks(); const pending = await deps.repo.listPendingPairingCodes(); return htmlPage(KiosksPage({ user: user.username, kiosks, pendingCodes: pending, error: (err as Error).message, })); } return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); }); // ---- Layouts --------------------------------------------------------------- app.get("/admin/layouts", async (event) => { const user = event.context.user!; const layouts = await deps.repo.listLayouts(); // For each layout, how many displays use it (for the list view). const displayCounts = new Map(); for (const l of layouts) { displayCounts.set(l.id, (await deps.repo.listDisplaysForLayout(l.id)).length); } return htmlPage(LayoutsPage({ user: user.username, layouts, displayCounts })); }); app.get("/admin/layouts/new", async (event) => { const user = event.context.user!; return htmlPage(LayoutNewPage({ user: user.username })); }); app.post("/admin/layouts/new", async (event) => { const user = event.context.user!; const body = await readBody>(event); const name = (body?.["name"] ?? "").trim(); const priority = body?.["priority"] ?? "normal"; const description = (body?.["description"] ?? "").trim() || null; const resetsIdleTimer = body?.["resets_idle_timer"] === "1"; const errors: string[] = []; if (!name || name.length > 128) errors.push("Name required (max 128 chars)."); if (priority !== "hot" && priority !== "normal" && priority !== "cold") { errors.push("Priority must be hot/normal/cold."); } if (errors.length > 0) { return htmlPage(LayoutNewPage({ user: user.username, error: errors.join(" "), values: body, })); } const layout = await deps.repo.createLayout({ name, description, priority, resets_idle_timer: resetsIdleTimer, }); return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layout.id}` } }); }); app.get("/admin/layouts/:id", async (event) => { const user = event.context.user!; const id = Number(getRouterParam(event, "id")); const layout = await deps.repo.getLayoutById(id); if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } }); const cells = await deps.repo.layoutCells(id); const cameras = await deps.repo.listCameras(); const entities = await deps.repo.listEntities(); const displays = await deps.repo.listDisplaysForLayout(id); return htmlPage(LayoutEditPage({ user: user.username, layout, displays, cells, cameras, entities, })); }); app.post("/admin/layouts/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const body = await readBody>(event); const coolingStr = body?.["cooling_timeout_seconds"] ?? ""; const coolingTimeout = coolingStr.trim() === "" ? null : parseInt(coolingStr, 10); await deps.repo.updateLayout(id, { name: body?.["name"], description: body?.["description"] || null, priority: (body?.["priority"] ?? "normal") as any, cooling_timeout_seconds: coolingTimeout, resets_idle_timer: body?.["resets_idle_timer"] === "1", }); notifyKiosks(); return new Response(null, { status: 302, headers: { location: `/admin/layouts/${id}` } }); }); // Create a new 1x1 cell. Two body shapes: // { position: { row, col } } — explicit position, may shift others. // { after_cell_id, direction } — relative to existing cell (right/below/left/above). // For htmx requests (hx-request header), returns the grid fragment; otherwise // returns a 302 to the layout edit page. app.post("/admin/layouts/:id/cells", async (event) => { const layoutId = Number(getRouterParam(event, "id")); const body = await readBody>(event); let row = 0; let col = 0; const afterCellIdRaw = body?.["after_cell_id"]; const direction = typeof body?.["direction"] === "string" ? (body["direction"] as string) : ""; if (afterCellIdRaw && direction) { const afterId = Number(afterCellIdRaw); const cells = await deps.repo.layoutCells(layoutId); const ref = cells.find((c) => c.id === afterId); if (!ref) { if (isHtmxRequest(event)) { const cameras = await deps.repo.listCameras(); const entities = await deps.repo.listEntities(); return htmlFragment(renderGrid(layoutId, cells, entities, cameras)); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); } if (direction === "right") { row = ref.row; col = ref.col + ref.col_span; await shiftCellsForInsertion(deps, layoutId, "col", col, row, row + 1); } else if (direction === "bottom") { row = ref.row + ref.row_span; col = ref.col; await shiftCellsForInsertion(deps, layoutId, "row", row, col, col + 1); } else if (direction === "left") { row = ref.row; col = Math.max(0, ref.col - 1); await shiftCellsForInsertion(deps, layoutId, "col", col, row, row + 1); } else if (direction === "above") { col = ref.col; row = Math.max(0, ref.row - 1); await shiftCellsForInsertion(deps, layoutId, "row", row, col, col + 1); } } else { // Explicit position — accept top-level row/col or nested position. const pos = body?.["position"]; if (pos && typeof pos === "object" && !Array.isArray(pos)) { row = Number((pos as { row: number; col: number }).row) || 0; col = Number((pos as { row: number; col: number }).col) || 0; } else { row = Number(body?.["row"] ?? 0) || 0; col = Number(body?.["col"] ?? 0) || 0; } } await deps.repo.createLayoutCell({ layout_id: layoutId, row, col, row_span: 1, col_span: 1, entity_id: null, }); notifyKiosks(); if (isHtmxRequest(event)) { const cells = await deps.repo.layoutCells(layoutId); const cameras = await deps.repo.listCameras(); const entities = await deps.repo.listEntities(); return htmlFragment(renderGrid(layoutId, cells, entities, cameras)); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); // GET a single cell in read mode (used by htmx Cancel button in inline edit). app.get("/admin/layouts/:id/cells/:cellId", async (event) => { const layoutId = Number(getRouterParam(event, "id")); const cellId = Number(getRouterParam(event, "cellId")); const cell = await deps.repo.getLayoutCellById(cellId); if (!cell || cell.layout_id !== layoutId) { return new Response("Not Found", { status: 404 }); } const cameras = await deps.repo.listCameras(); const entities = await deps.repo.listEntities(); return htmlFragment(renderCell(layoutId, cell, entities, cameras, "read")); }); // GET a single cell in edit mode (htmx swap target for cell click). app.get("/admin/layouts/:id/cells/:cellId/edit", async (event) => { const layoutId = Number(getRouterParam(event, "id")); const cellId = Number(getRouterParam(event, "cellId")); const cell = await deps.repo.getLayoutCellById(cellId); if (!cell || cell.layout_id !== layoutId) { return new Response("Not Found", { status: 404 }); } const cameras = await deps.repo.listCameras(); const entities = await deps.repo.listEntities(); return htmlFragment(renderCell(layoutId, cell, entities, cameras, "edit")); }); // Update a cell's entity binding + dimensions. Legacy content_type/web/html // columns are managed by assignCellEntity for bundle compatibility. app.post("/admin/layouts/:id/cells/:cellId", async (event) => { const layoutId = Number(getRouterParam(event, "id")); const cellId = Number(getRouterParam(event, "cellId")); const body = await readBody>(event); const entityIdRaw = body?.["entity_id"]; const entityId = entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null; await deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : null); // stream_selector + spans + fit are still settable on the cell. const dimsPatch: Record = {}; const streamSelector = body?.["stream_selector"]; if (streamSelector === "auto" || streamSelector === "main" || streamSelector === "sub") { dimsPatch["stream_selector"] = streamSelector; } const fit = body?.["fit"]; if (fit === "cover" || fit === "contain" || fit === "fill") { dimsPatch["fit"] = fit; } const colSpanRaw = body?.["col_span"]; const rowSpanRaw = body?.["row_span"]; if (colSpanRaw != null && String(colSpanRaw).trim() !== "") { dimsPatch["col_span"] = Math.max(1, Number(colSpanRaw) || 1); } if (rowSpanRaw != null && String(rowSpanRaw).trim() !== "") { dimsPatch["row_span"] = Math.max(1, Number(rowSpanRaw) || 1); } let spansChanged = false; if (Object.keys(dimsPatch).length > 0) { await deps.repo.updateLayoutCell(cellId, dimsPatch as any); if ("col_span" in dimsPatch || "row_span" in dimsPatch) { spansChanged = true; const axis = "col_span" in dimsPatch ? "col" as const : "row" as const; await resolveOverlaps(deps, layoutId, cellId, axis); } } // Parse smart URL steps from the step builder form fields. const steps: Array> = []; for (let i = 0; i < 50; i++) { const stepType = body?.[`step_${i}_type`]; const stepValue = body?.[`step_${i}_value`]; if (!stepType) break; const step: Record = { type: stepType }; if (stepType === "navigate") step["url"] = stepValue; else if (stepType === "fill" && stepValue?.includes("=")) { const eqIdx = stepValue.indexOf("="); step["selector"] = stepValue.slice(0, eqIdx); step["value"] = stepValue.slice(eqIdx + 1); } else if (stepType === "click" || stepType === "wait_for") step["selector"] = stepValue; else if (stepType === "wait") step["delay_ms"] = Number(stepValue) || 1000; else if (stepType === "javascript") step["script"] = stepValue; steps.push(step); } const loginDetect = (body?.["smart_url_login_detect"] ?? "").trim(); const updatedCell = await deps.repo.getLayoutCellById(cellId); if (updatedCell) { const opts = { ...(updatedCell.options ?? {}) }; if (steps.length > 0) { opts["smart_url"] = { steps, ...(loginDetect ? { login_detect_url: loginDetect } : {}), }; } else { delete opts["smart_url"]; } await deps.repo.updateLayoutCell(cellId, { options: JSON.stringify(opts) } as any); } notifyKiosks(); if (isHtmxRequest(event)) { if (spansChanged) { const cells = await deps.repo.layoutCells(layoutId); const cameras = await deps.repo.listCameras(); const entities = await deps.repo.listEntities(); const body = String(renderGrid(layoutId, cells, entities, cameras)); return new Response(body, { headers: { "content-type": "text/html; charset=utf-8", "hx-retarget": "#layout-grid", "hx-reswap": "innerHTML", }, }); } const cell = await deps.repo.getLayoutCellById(cellId); if (!cell) return new Response("", { headers: { "content-type": "text/html; charset=utf-8" } }); const cameras = await deps.repo.listCameras(); const entities = await deps.repo.listEntities(); return htmlFragment(renderCell(layoutId, cell, entities, cameras, "read")); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); // Resize a cell by ±1 on row_span or col_span. Returns the grid fragment. app.post("/admin/layouts/:id/cells/:cellId/resize", async (event) => { const layoutId = Number(getRouterParam(event, "id")); const cellId = Number(getRouterParam(event, "cellId")); const body = await readBody>(event); const dim = String(body?.["dim"] ?? ""); const delta = Number(body?.["delta"] ?? 0) || 0; const direction = String(body?.["direction"] ?? ""); const cell = await deps.repo.getLayoutCellById(cellId); if ( cell && cell.layout_id === layoutId && (direction === "left" || direction === "right" || direction === "above" || direction === "bottom") ) { await shiftCellsForExpansion(deps, layoutId, cellId, direction); notifyKiosks(); } else if (cell && cell.layout_id === layoutId && (dim === "row_span" || dim === "col_span") && delta !== 0) { const current = dim === "row_span" ? cell.row_span : cell.col_span; const next = Math.max(1, current + delta); if (next !== current) { await deps.repo.updateLayoutCell(cellId, { [dim]: next } as any); await resolveOverlaps(deps, layoutId, cellId, dim === "col_span" ? "col" : "row"); notifyKiosks(); } } const cells = await deps.repo.layoutCells(layoutId); const cameras = await deps.repo.listCameras(); const entities = await deps.repo.listEntities(); if (isHtmxRequest(event)) { return htmlFragment(renderGrid(layoutId, cells, entities, cameras)); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); // Visual editor: drag-to-move a cell to a new grid position. app.post("/admin/layouts/:id/cells/:cellId/move", async (event) => { const layoutId = Number(getRouterParam(event, "id")); const cellId = Number(getRouterParam(event, "cellId")); const body = await readBody<{ row: number; col: number }>(event); const row = Number(body?.row ?? 0); const col = Number(body?.col ?? 0); if (Number.isInteger(row) && Number.isInteger(col) && row >= 0 && col >= 0) { await deps.repo.updateLayoutCell(cellId, { row, col } as any); notifyKiosks(); } return { ok: true }; }); app.post("/admin/layouts/:id/cells/:cellId/delete", async (event) => { const layoutId = Number(getRouterParam(event, "id")); const cellId = Number(getRouterParam(event, "cellId")); await deps.repo.deleteLayoutCell(cellId); notifyKiosks(); if (isHtmxRequest(event)) { const cells = await deps.repo.layoutCells(layoutId); const cameras = await deps.repo.listCameras(); const entities = await deps.repo.listEntities(); return htmlFragment(renderGrid(layoutId, cells, entities, cameras)); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); app.post("/admin/layouts/:id/clone", async (event) => { const id = Number(getRouterParam(event, "id")); const clone = await deps.repo.cloneLayout(id); notifyKiosks(); return new Response(null, { status: 302, headers: { location: `/admin/layouts/${clone.id}` } }); }); app.post("/admin/layouts/:id/delete", async (event) => { const id = Number(getRouterParam(event, "id")); await deps.repo.deleteLayout(id); notifyKiosks(); return new Response(null, { status: 302, headers: { location: "/admin/layouts" } }); }); // ---- Displays -------------------------------------------------------------- app.get("/admin/displays", async (event) => { const user = event.context.user!; const displays = await deps.repo.listDisplays(); return htmlPage(DisplaysPage({ user: user.username, displays })); }); app.get("/admin/displays/:id", async (event) => { const user = event.context.user!; const id = Number(getRouterParam(event, "id")); const display = await deps.repo.getDisplayById(id); if (!display) return new Response(null, { status: 302, headers: { location: "/admin/displays" } }); const attachedLayouts = await deps.repo.listLayoutsForDisplay(id); const attachedIds = new Set(attachedLayouts.map((l) => l.id)); const availableLayouts = (await deps.repo.listLayouts()).filter((l) => !attachedIds.has(l.id)); const kiosk = display.kiosk_id ? await deps.repo.getKioskById(display.kiosk_id) : null; return htmlPage(DisplayEditPage({ user: user.username, display, attachedLayouts, availableLayouts, kioskName: kiosk?.name ?? null, })); }); app.post("/admin/displays/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const body = await readBody>(event); const defaultLayoutIdRaw = body?.["default_layout_id"]; const defaultLayoutId = defaultLayoutIdRaw ? Number(defaultLayoutIdRaw) : null; // Validate default_layout_id is actually attached to this display. let validatedDefault: number | null = defaultLayoutId; if (defaultLayoutId != null) { const attached = await deps.repo.listLayoutsForDisplay(id); if (!attached.some((l) => l.id === defaultLayoutId)) { validatedDefault = null; } } // width/height are no longer admin-editable — they come from the kiosk's // hardware report. Just update the editable fields. await deps.repo.updateDisplay(id, { name: body?.["name"], default_layout_id: validatedDefault, idle_timeout_seconds: parseInt(body?.["idle_timeout_seconds"] ?? "0", 10), sleep_timeout_seconds: parseInt(body?.["sleep_timeout_seconds"] ?? "0", 10), is_enabled: body?.["is_enabled"] === "on" || body?.["is_enabled"] === "1" ? 1 : 0, } as any); notifyKiosks(); return new Response(null, { status: 302, headers: { location: `/admin/displays/${id}` } }); }); // Render the attached + available layouts region for a display. const renderDisplayLayoutsFragment = async (displayId: number): Promise => { const display = await deps.repo.getDisplayById(displayId); const attached = await deps.repo.listLayoutsForDisplay(displayId); const attachedIds = new Set(attached.map((l) => l.id)); const available = (await deps.repo.listLayouts()).filter((l) => !attachedIds.has(l.id)); return htmlFragment( renderDisplayLayouts(displayId, display?.default_layout_id ?? null, attached, available) + renderDefaultLayoutSelect(display?.default_layout_id ?? null, attached, true), ); }; // Attach a layout to a display. app.post("/admin/displays/:id/layouts", async (event) => { const displayId = Number(getRouterParam(event, "id")); const body = await readBody>(event); const layoutId = body?.["layout_id"] ? Number(body["layout_id"]) : null; if (layoutId && Number.isFinite(layoutId)) { await deps.repo.attachLayoutToDisplay(displayId, layoutId); notifyKiosks(); } if (isHtmxRequest(event)) { return await renderDisplayLayoutsFragment(displayId); } return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } }); }); // Detach a layout from a display. app.post("/admin/displays/:id/layouts/:layoutId/remove", async (event) => { const displayId = Number(getRouterParam(event, "id")); const layoutId = Number(getRouterParam(event, "layoutId")); await deps.repo.detachLayoutFromDisplay(displayId, layoutId); notifyKiosks(); if (isHtmxRequest(event)) { return await renderDisplayLayoutsFragment(displayId); } return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } }); }); app.get("/admin/labels", async (event) => { const user = event.context.user!; return htmlPage(LabelsPage({ user: user.username, labels: await deps.repo.listLabels() })); }); app.post("/admin/labels/new", async (event) => { const body = await readBody>(event); const name = (body?.["name"] ?? "").trim().toLowerCase(); const color = body?.["color"] ?? null; if (!name || !/^[a-z0-9][a-z0-9_-]*$/.test(name)) { return htmlPage(LabelsPage({ user: event.context.user!.username, labels: await deps.repo.listLabels(), error: "Label name must start with letter/digit and contain only lowercase, digits, hyphens, underscores.", })); } await deps.repo.createLabel({ name, color }); return new Response(null, { status: 302, headers: { location: "/admin/labels" } }); }); app.post("/admin/labels/:id/delete", async (event) => { const id = Number(getRouterParam(event, "id")); await deps.repo.deleteLabel(id); return new Response(null, { status: 302, headers: { location: "/admin/labels" } }); }); // ---- Camera edit/delete/labels -------------------------------------------- app.get("/admin/cameras/:id", async (event) => { const user = event.context.user!; const id = Number(getRouterParam(event, "id")); const camera = await deps.repo.getCameraById(id); if (!camera) return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); // Build subscription list: which kiosks have this camera in any layout? const bundleKiosks = await deps.repo.listKiosksWithCameraInBundle(id); const activeKiosks = new Set((await deps.repo.listKiosksRenderingCamera(id)).map((k) => k.id)); const subscriptions = []; for (const k of bundleKiosks) { // Find layout names that reference this camera on this kiosk's displays const displays = await deps.repo.listDisplaysForKiosk(k.id); const layoutNames: string[] = []; for (const d of displays) { const layouts = await deps.repo.listLayoutsForDisplay(d.id); for (const l of layouts) { const cells = await deps.repo.listLayoutCells(l.id); if (cells.some((c) => c.camera_id === id)) { layoutNames.push(l.name); } } } subscriptions.push({ kiosk: k, layouts: layoutNames, active: activeKiosks.has(k.id), }); } return htmlPage(CameraEditPage({ user: user.username, camera, labels: await deps.repo.cameraLabelIds(id), allLabels: await deps.repo.listLabels(), streams: await deps.repo.listCameraStreams(id), subscriptions, })); }); app.post("/admin/cameras/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const cam = await deps.repo.getCameraById(id); if (cam?.type === "cloud") { return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); } const body = await readBody>(event); let rtspUrl: string | null = null; if (cam?.type === "rtsp") { const host = (body?.["rtsp_host"] ?? "").trim(); const port = (body?.["rtsp_port"] ?? "554").trim(); const path = (body?.["rtsp_path"] ?? "").trim(); const user = (body?.["rtsp_username"] ?? "").trim(); const pass = body?.["rtsp_password"] ?? ""; if (host) { // If password blank, keep old URL (password unchanged) if (!pass && cam.rtsp_url) { const oldParts = cam.rtsp_url.match(/^rtsp:\/\/(?:([^@]+)@)?/); const oldUserinfo = oldParts?.[1] ?? ""; const userPart = oldUserinfo ? `${oldUserinfo}@` : ""; const pathPart = path.startsWith("/") ? path : `/${path}`; rtspUrl = `rtsp://${userPart}${host}:${port}${pathPart}`; } else { const userPart = user ? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@` : ""; const pathPart = path.startsWith("/") ? path : `/${path}`; rtspUrl = `rtsp://${userPart}${host}:${port}${pathPart}`; } } } const patch: Record = { name: body?.["name"], enabled: body?.["enabled"] === "1", }; if (cam?.type === "rtsp" && rtspUrl) { patch["rtsp_url"] = rtspUrl; } else if (cam?.type === "onvif") { patch["onvif_host"] = body?.["onvif_host"] || null; patch["onvif_port"] = body?.["onvif_port"] ? Number(body["onvif_port"]) : null; patch["onvif_username"] = body?.["onvif_username"] || null; if (body?.["onvif_password"]) patch["onvif_password"] = body["onvif_password"]; } // Event routing config if (body?.["event_source"] != null) patch["event_source"] = body["event_source"] || "auto"; if (body?.["event_sink"] != null) patch["event_sink"] = body["event_sink"] || "auto"; await deps.repo.updateCamera(id, patch as any); // Also update main stream URI for RTSP cameras if (cam?.type === "rtsp" && rtspUrl) { const streams = await deps.repo.listCameraStreams(id); const mainStream = streams.find((s) => s.role === "main"); if (mainStream) { await deps.repo.updateCameraStream(mainStream.id, { rtsp_uri: rtspUrl }); } else { await deps.repo.createCameraStream({ camera_id: id, role: "main", name: "Main", rtsp_uri: rtspUrl, }); } } // Sync entity name when camera name changes. if (patch["name"]) { const ent = await deps.repo.getEntityForCamera(id); if (ent && ent.name !== patch["name"]) { await deps.repo.updateEntity(ent.id, { name: patch["name"] } as any); } } notifyKiosks(); deps.nodered.forward("camera.changed", { camera_id: id, event: "updated", source: "server" }); return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); }); app.post("/admin/cameras/:id/labels", async (event) => { const camId = Number(getRouterParam(event, "id")); const body = await readBody>(event); const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase(); let labelId = body?.["label_id"] ? Number(body["label_id"]) : null; if (newLabel) { const label = await deps.repo.ensureLabel(newLabel); labelId = label.id; } if (labelId) { await deps.repo.attachCameraLabel(camId, labelId); } if (isHtmxRequest(event)) { return htmlFragment(renderCameraLabels(camId, await deps.repo.cameraLabelIds(camId), await deps.repo.listLabels())); } return new Response(null, { status: 302, headers: { location: `/admin/cameras/${camId}` } }); }); app.post("/admin/cameras/:id/labels/remove", async (event) => { const camId = Number(getRouterParam(event, "id")); const body = await readBody>(event); const labelId = Number(body?.["label_id"]); await deps.repo.detachCameraLabel(camId, labelId); if (isHtmxRequest(event)) { return htmlFragment(renderCameraLabels(camId, await deps.repo.cameraLabelIds(camId), await deps.repo.listLabels())); } return new Response(null, { status: 302, headers: { location: `/admin/cameras/${camId}` } }); }); // Refresh supported ONVIF event topics from the camera. app.post("/admin/cameras/:id/refresh-events", async (event) => { const id = Number(getRouterParam(event, "id")); const cam = await deps.repo.getCameraById(id); if (!cam || cam.type !== "onvif" || !cam.onvif_host) { return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); } // Determine which kiosk (or server) to run the SOAP call through. let runner: string; if (cam.event_source === "server") { runner = "server"; } else if (cam.event_source.startsWith("kiosk:")) { runner = cam.event_source; } else { // Auto: pick a kiosk that has this camera in its bundle. const kiosks = await deps.repo.listKiosksWithCameraInBundle(id); const online = kiosks.find((k) => k.last_seen_at && Date.now() - new Date(k.last_seen_at).getTime() < 120_000); runner = online ? `kiosk:${online.id}` : "server"; } const soapTransport = runner.startsWith("kiosk:") ? kioskOnvifSoapTransport(Number(runner.slice("kiosk:".length))) : undefined; try { const topics = await onvifGetEventProperties({ host: cam.onvif_host, port: cam.onvif_port ?? 80, username: cam.onvif_username ?? "", password: cam.onvif_password ?? "", soapTransport, }); await deps.repo.updateCamera(id, { supported_event_topics: JSON.stringify(topics) } as any); } catch { // Camera offline or events not supported — leave existing topics. } return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); }); app.post("/admin/cameras/:id/delete", async (event) => { const id = Number(getRouterParam(event, "id")); await deps.repo.deleteCamera(id); notifyKiosks(); deps.nodered.forward("camera.changed", { camera_id: id, event: "deleted", source: "server" }); 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, "&").replace(//g, ">").replace(/"/g, """); app.get("/admin/cameras/:id/events", async (event) => { const id = Number(getRouterParam(event, "id")); const { events } = await deps.repo.queryEvents({ camera_id: id, limit: 20, }); if (events.length === 0) { return htmlFragment( `
No events yet. ONVIF events appear here as the kiosk receives them.
`, ); } const rows = events.map((e) => { let payload = ""; try { payload = JSON.stringify(e.payload, null, 1); } catch { payload = String(e.payload); } return ` ${formatTimeShort(e.received_at)} ${escapeHtml(e.topic)} ${escapeHtml(e.source_type)}
${escapeHtml(payload)}
`; }).join(""); return htmlFragment( `${rows}
TimeTopicSourcePayload
`, ); }); // ---- Kiosk edit/delete/labels --------------------------------------------- app.get("/admin/kiosks/:id", async (event) => { const user = event.context.user!; const id = Number(getRouterParam(event, "id")); const kiosk = await deps.repo.getKioskById(id); if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); const kioskLabels = (await deps.repo.listKioskLabels(id)).map((kl) => ({ label_id: kl.label_id, name: kl.name, role: kl.role, })); const displays = await deps.repo.listDisplaysForKiosk(id); const displayLayouts = []; for (const display of displays) { displayLayouts.push({ display, layouts: await deps.repo.listLayoutsForDisplay(display.id), }); } const gpioBindings = await deps.repo.listGpioBindings(id); const firmwareReleases = await deps.repo.listFirmwareReleases(); const osReleases = await deps.repo.listOsUpdateReleases(); const logResult = await deps.repo.queryKioskLogs({ kiosk_id: id, limit: 50 }); return htmlPage(KioskEditPage({ user: user.username, kiosk, labels: kioskLabels, allLabels: await deps.repo.listLabels(), displays, displayLayouts, gpioBindings, firmwareReleases, osReleases, kioskLogs: logResult.logs, kioskLogTotal: logResult.total, })); }); // ---- GPIO bindings ---------------------------------------------------- app.post("/admin/kiosks/:id/gpio", async (event) => { const kioskId = Number(getRouterParam(event, "id")); const body = await readBody>(event); const pin = Number(body?.["pin"]); const direction = (body?.["direction"] ?? "in") === "out" ? "out" : "in"; const pullRaw = body?.["pull"]; const pull = pullRaw === "up" || pullRaw === "down" || pullRaw === "none" ? pullRaw : null; const edgeRaw = body?.["edge"]; const edge = edgeRaw === "rising" || edgeRaw === "falling" || edgeRaw === "both" ? edgeRaw : null; const chip = (body?.["chip"] ?? "gpiochip0").trim() || "gpiochip0"; const topic = (body?.["topic"] ?? "").trim(); if (Number.isFinite(pin) && topic) { await deps.repo.createGpioBinding({ kiosk_id: kioskId, chip, pin, direction, pull, edge, topic, }); notifyKiosks(); } return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); }); app.post("/admin/kiosks/:id/gpio/:bindingId/delete", async (event) => { const kioskId = Number(getRouterParam(event, "id")); const bindingId = Number(getRouterParam(event, "bindingId")); await deps.repo.deleteGpioBinding(bindingId); notifyKiosks(); if (isHtmxRequest(event)) { // Row is swapped via hx-target="closest tr" hx-swap="outerHTML" — empty // response collapses the row out of the table. return htmlFragment(""); } return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); }); app.post("/admin/kiosks/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const body = await readBody>(event); const kiosk = await deps.repo.getKioskById(id); await deps.repo.updateKiosk(id, { name: body?.["name"], enabled: body?.["enabled"] === "1", } as any); if (kiosk?.managed_image && body?.["name"]) { const cfg = kiosk.managed_config_json ? JSON.parse(kiosk.managed_config_json) : {}; const hostname = hostnameFromName(body["name"]); if (cfg?.hostname !== hostname) { await deps.repo.updateKiosk(id, { managed_config_json: JSON.stringify({ ...cfg, hostname }), managed_config_version: kiosk.managed_config_version + 1, managed_config_error: null, } as any); } } return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); // Managed-image device config — admin pushes hostname/timezone/network/wifi // for kiosks running our pre-built Pi image. Builds a ManagedConfig object, // encrypts the wifi PSK with the cluster key (so it can be stored at rest // and delivered as ciphertext that the kiosk decrypts on-device with the // cluster key it received at pairing), then bumps managed_config_version // so the next heartbeat ships it to the kiosk. app.post("/admin/kiosks/:id/managed-config", async (event) => { const id = Number(getRouterParam(event, "id")); const kiosk = await deps.repo.getKioskById(id); if (!kiosk) throw new Error("kiosk not found"); if (!kiosk.managed_image) throw new Error("kiosk is not running a managed image"); const body = await readBody>(event); const trim = (v: string | undefined) => (v ?? "").trim(); const cfg: Record = {}; const hostname = trim(body?.["hostname"]) || hostnameFromName(kiosk.name); if (hostname) cfg["hostname"] = hostname; const timezone = trim(body?.["timezone"]); if (timezone) cfg["timezone"] = timezone; const netMode = trim(body?.["network_mode"]); if (netMode === "dhcp" || netMode === "static") { const net: Record = { mode: netMode }; const iface = trim(body?.["network_interface"]); if (iface) net["interface"] = iface; if (netMode === "static") { const ipCidr = trim(body?.["network_ip_cidr"]); if (ipCidr) net["ip_cidr"] = ipCidr; const gw = trim(body?.["network_gateway"]); if (gw) net["gateway"] = gw; const dnsRaw = trim(body?.["network_dns"]); if (dnsRaw) { net["dns"] = dnsRaw.split(",").map((s) => s.trim()).filter(Boolean); } } const vlanRaw = trim(body?.["network_vlan_id"]); if (vlanRaw) { const vlanId = Number(vlanRaw); if (Number.isInteger(vlanId) && vlanId >= 1 && vlanId <= 4094) { net["vlan_id"] = vlanId; } } cfg["network"] = net; } // Wifi: load existing first so blank PSK = "keep current". Re-encrypt PSK // only when the operator actually typed one. const ssid = trim(body?.["wifi_ssid"]); const pskPlaintext = body?.["wifi_psk"] ?? ""; if (ssid) { const prev = kiosk.managed_config_json ? JSON.parse(kiosk.managed_config_json) : null; let pskCiphertext: string | null = prev?.wifi?.psk_ciphertext ?? null; if (pskPlaintext) { pskCiphertext = deps.secrets.encryptString(pskPlaintext, "cluster"); } if (pskCiphertext) { cfg["wifi"] = { ssid, psk_ciphertext: pskCiphertext }; } } await deps.repo.updateKiosk(id, { managed_config_json: JSON.stringify(cfg), managed_config_version: kiosk.managed_config_version + 1, managed_config_error: null, } as any); await audit(deps.repo, event as any, "kiosk.managed_config.update", { resource_type: "kiosk", resource_id: String(id), metadata: { version: kiosk.managed_config_version + 1 }, }); return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); app.post("/admin/kiosks/:id/labels", async (event) => { const kioskId = Number(getRouterParam(event, "id")); const body = await readBody>(event); const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase(); const role = (body?.["role"] ?? "consume") as "consume" | "operate"; let labelId = body?.["label_id"] ? Number(body["label_id"]) : null; if (newLabel) { const label = await deps.repo.ensureLabel(newLabel); labelId = label.id; } if (labelId) { await deps.repo.attachKioskLabel(kioskId, labelId, role); } if (isHtmxRequest(event)) { const kioskLabels = (await deps.repo.listKioskLabels(kioskId)).map((kl) => ({ label_id: kl.label_id, name: kl.name, role: kl.role, })); return htmlFragment(renderKioskLabels(kioskId, kioskLabels, await deps.repo.listLabels())); } return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); }); app.post("/admin/kiosks/:id/labels/remove", async (event) => { const kioskId = Number(getRouterParam(event, "id")); const body = await readBody>(event); const labelId = Number(body?.["label_id"]); await deps.repo.detachKioskLabel(kioskId, labelId); if (isHtmxRequest(event)) { const kioskLabels = (await deps.repo.listKioskLabels(kioskId)).map((kl) => ({ label_id: kl.label_id, name: kl.name, role: kl.role, })); return htmlFragment(renderKioskLabels(kioskId, kioskLabels, await deps.repo.listLabels())); } return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); }); app.post("/admin/kiosks/:id/delete", async (event) => { const id = Number(getRouterParam(event, "id")); await deps.repo.deleteKiosk(id); return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); }); // ---- Kiosk debug (journal + terminal) pages ---------------------------- // These are simple HTML pages that connect to the admin debug WS at // /ws/admin/debug/:kioskId and render output. The WS connection is // authenticated via the admin's API key. app.get("/admin/kiosks/:id/logs", async (event) => { const id = Number(getRouterParam(event, "id")); const kiosk = await deps.repo.getKioskById(id); if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); const user = event.context.user!; // Get or create an API key for the WS connection. // WS auth: browser sends session cookie automatically on WS upgrade. // Coordinator WS endpoint validates via resolveSession. return htmlPage(`Logs: ${kiosk.name}
← ${kiosk.name}

      `);
  });

  app.get("/admin/kiosks/:id/terminal", async (event) => {
    const id = Number(getRouterParam(event, "id"));
    const kiosk = await deps.repo.getKioskById(id);
    if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
    // WS auth: browser sends session cookie automatically on WS upgrade.
    // Coordinator WS endpoint validates via resolveSession.
    return htmlPage(`Terminal: ${kiosk.name}
      
      
← ${kiosk.name} Disconnected
`); }); // ---- Layout switch ---------------------------------------------------- const emitLayoutChanged = async (displayId: number | null, kioskId: number | null, layoutId: number) => { const layout = await deps.repo.getLayoutById(layoutId); deps.nodered.forward("layout.changed", { display_id: displayId, kiosk_id: kioskId, layout_id: layoutId, layout_name: layout?.name ?? null, source: "server", }); }; const displayLayoutSwitch = async (event: any) => { const displayId = Number(getRouterParam(event, "displayId")); let layoutId = Number(getRouterParam(event, "layoutId")); if (!Number.isFinite(layoutId) || layoutId <= 0) { const body = await readBody>(event); layoutId = Number(body?.["layout_id"]); } if (Number.isFinite(displayId) && Number.isFinite(layoutId)) { const display = await deps.repo.getDisplayById(displayId); const attached = await deps.repo.listLayoutsForDisplay(displayId); const isAttached = attached.some((l) => l.id === layoutId); if (display?.kiosk_id && isAttached) { getCoordinator().sendToKiosk(display.kiosk_id, { type: "layout-switch", display_id: displayId, layout_id: layoutId, }); await emitLayoutChanged(displayId, display.kiosk_id, layoutId); } } return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } }); }; app.post("/admin/displays/:displayId/layout", displayLayoutSwitch); app.post("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch); app.get("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch); const displayPower = async (event: any, state: "on" | "standby") => { const id = Number(getRouterParam(event, "id")); const display = await deps.repo.getDisplayById(id); if (display?.kiosk_id) { getCoordinator().sendToKiosk(display.kiosk_id, { type: state === "on" ? "wake" : "standby", display_id: id, }); await deps.repo.updateDisplay(id, { actual_power_state: state === "on" ? "awake" : "standby", actual_power_state_at: new Date().toISOString(), } as any); deps.nodered.forward("display.power.changed", { display_id: id, kiosk_id: display.kiosk_id, state, source: "server", }); } return new Response(null, { status: 302, headers: { location: `/admin/displays/${id}` } }); }; app.post("/admin/displays/:id/power/standby", (event) => displayPower(event, "standby")); app.post("/admin/displays/:id/power/wake", (event) => displayPower(event, "on")); // Node-RED embedded page app.get("/admin/nodered", async (event) => { const user = event.context.user!; return htmlPage(NoderedEmbedPage({ user: user.username })); }); // ---- CEC power commands ----------------------------------------------- const emitDisplayPower = async (kioskId: number, state: "on" | "standby") => { const displays = await deps.repo.listDisplaysForKiosk(kioskId); const displayId = displays[0]?.id ?? null; const actual = state === "on" ? "awake" : "standby"; const at = new Date().toISOString(); for (const display of displays) { await deps.repo.updateDisplay(display.id, { actual_power_state: actual, actual_power_state_at: at, } as any); } deps.nodered.forward("display.power.changed", { display_id: displayId, kiosk_id: kioskId, state, source: "server", }); }; app.post("/admin/kiosks/:id/power/standby", async (event) => { const id = Number(getRouterParam(event, "id")); getCoordinator().sendToKiosk(id, { type: "standby" }); await emitDisplayPower(id, "standby"); await audit(deps.repo, event as any, "display.standby", { resource_type: "kiosk", resource_id: id }); return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); app.post("/admin/kiosks/:id/power/wake", async (event) => { const id = Number(getRouterParam(event, "id")); getCoordinator().sendToKiosk(id, { type: "wake" }); await emitDisplayPower(id, "on"); await audit(deps.repo, event as any, "display.wake", { resource_type: "kiosk", resource_id: id }); return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); // ---- Fan control ------------------------------------------------------ app.post("/admin/kiosks/:id/fan", async (event) => { const id = Number(getRouterParam(event, "id")); const body = await readBody>(event); const mode = body?.["mode"]; if (mode === "auto") { getCoordinator().sendToKiosk(id, { type: "fan", mode: "auto" }); } else { const pwm = Math.max(0, Math.min(255, Number(body?.["pwm"]) || 0)); getCoordinator().sendToKiosk(id, { type: "fan", pwm }); } return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); // ---- JSON API (admin scope) — used by Node-RED bf-* nodes --------------- // // All payloads run through `stripSecrets` so credential-bearing fields // (key_hash, onvif_password, totp_secret_encrypted, etc.) never leak to // automation clients. List shapes are kept thin (id/name/type/enabled + // labels where useful); detail shapes return the full row minus secrets. app.get("/api/admin/cameras", async (_event) => { const cameras = await deps.repo.listCameras(); const payload = []; for (const c of cameras) { payload.push({ id: c.id, name: c.name, type: c.type, enabled: c.enabled, labels: await deps.repo.cameraLabelNames(c.id), }); } return jsonResponse({ cameras: payload }); }); app.get("/api/admin/cameras/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const cam = await deps.repo.getCameraById(id); if (!cam) return jsonResponse({ error: "not_found" }, 404); const streams = await deps.repo.listCameraStreams(id); return jsonResponse({ camera: { ...cam, labels: await deps.repo.cameraLabelNames(id), streams }, }); }); app.get("/api/admin/displays", async (_event) => { const displays = await deps.repo.listDisplays(); return jsonResponse({ displays }); }); app.get("/api/admin/displays/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const display = await deps.repo.getDisplayById(id); if (!display) return jsonResponse({ error: "not_found" }, 404); const attachedLayouts = await deps.repo.listLayoutsForDisplay(id); return jsonResponse({ display: { ...display, attached_layouts: attachedLayouts } }); }); app.get("/api/admin/kiosks", async (_event) => { const kiosks = await deps.repo.listKiosks(); const now = Date.now(); const payload = kiosks.map((k) => ({ id: k.id, name: k.name, enabled: k.enabled, hardware_model: k.hardware_model, last_seen_at: k.last_seen_at, online: k.last_seen_at ? now - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000 : false, cpu_temp_c: k.cpu_temp_c, fan_rpm: k.fan_rpm, fan_pwm: k.fan_pwm, })); return jsonResponse({ kiosks: payload }); }); app.get("/api/admin/kiosks/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const kiosk = await deps.repo.getKioskById(id); if (!kiosk) return jsonResponse({ error: "not_found" }, 404); const displays = await deps.repo.listDisplaysForKiosk(id); const labels = (await deps.repo.listKioskLabels(id)).map((kl) => ({ label_id: kl.label_id, name: kl.name, role: kl.role, })); return jsonResponse({ kiosk: { ...kiosk, displays, labels } }); }); app.get("/api/admin/layouts", async (_event) => { const layouts = await deps.repo.listLayouts(); return jsonResponse({ layouts }); }); app.get("/api/admin/layouts/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const layout = await deps.repo.getLayoutById(id); if (!layout) return jsonResponse({ error: "not_found" }, 404); const cells = await deps.repo.layoutCells(id); const displays = await deps.repo.listDisplaysForLayout(id); return jsonResponse({ layout: { ...layout, cells, displays } }); }); app.get("/api/admin/entities", async (_event) => { const entities = await deps.repo.listEntities(); return jsonResponse({ entities }); }); app.get("/api/admin/entities/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const entity = await deps.repo.getEntityById(id); if (!entity) return jsonResponse({ error: "not_found" }, 404); return jsonResponse({ entity }); }); // ---- JSON mutation API — used by Node-RED bf-config-set node ------------ // // Body shape: { value: } — keeps the wire format uniform // across all set ops. Returns the post-mutation entity. app.post("/api/admin/displays/:id/default-layout", async (event) => { const id = Number(getRouterParam(event, "id")); const body = (await readBody>(event)) ?? {}; const raw = body["value"] ?? body["default_layout_id"]; const layoutId = raw == null || raw === "" ? null : Number(raw); if (raw != null && raw !== "" && !Number.isFinite(layoutId)) { return jsonResponse({ error: "invalid_value" }, 400); } if (layoutId != null) { const attached = await deps.repo.listLayoutsForDisplay(id); if (!attached.some((l) => l.id === layoutId)) { return jsonResponse({ error: "layout_not_attached" }, 400); } } await deps.repo.updateDisplay(id, { default_layout_id: layoutId } as any); notifyKiosks(); const display = await deps.repo.getDisplayById(id); return jsonResponse({ display }); }); app.post("/api/admin/kiosks/:id/enabled", async (event) => { const id = Number(getRouterParam(event, "id")); const body = (await readBody>(event)) ?? {}; const enabled = Boolean(body["value"] ?? body["enabled"]); await deps.repo.updateKiosk(id, { enabled } as any); const kiosk = await deps.repo.getKioskById(id); if (!kiosk) return jsonResponse({ error: "not_found" }, 404); return jsonResponse({ kiosk }); }); app.post("/api/admin/cameras/:id/enabled", async (event) => { const id = Number(getRouterParam(event, "id")); const body = (await readBody>(event)) ?? {}; const enabled = Boolean(body["value"] ?? body["enabled"]); await deps.repo.updateCamera(id, { enabled } as any); notifyKiosks(); deps.nodered.forward("camera.changed", { camera_id: id, event: "updated", source: "server" }); const camera = await deps.repo.getCameraById(id); if (!camera) return jsonResponse({ error: "not_found" }, 404); return jsonResponse({ camera }); }); app.post("/api/admin/layouts/:id/priority", async (event) => { const id = Number(getRouterParam(event, "id")); const body = (await readBody>(event)) ?? {}; const value = String(body["value"] ?? body["priority"] ?? "").toLowerCase(); if (value !== "hot" && value !== "normal" && value !== "cold") { return jsonResponse({ error: "invalid_priority" }, 400); } await deps.repo.updateLayout(id, { priority: value } as any); notifyKiosks(); const layout = await deps.repo.getLayoutById(id); if (!layout) return jsonResponse({ error: "not_found" }, 404); return jsonResponse({ layout }); }); app.post("/api/admin/entities/:id/name", async (event) => { const id = Number(getRouterParam(event, "id")); const body = (await readBody>(event)) ?? {}; const name = String(body["value"] ?? body["name"] ?? "").trim(); if (!name || name.length > 128) { return jsonResponse({ error: "invalid_name" }, 400); } const existing = await deps.repo.getEntityByName(name); if (existing && existing.id !== id) { return jsonResponse({ error: "name_in_use" }, 400); } await deps.repo.updateEntity(id, { name }); notifyKiosks(); const entity = await deps.repo.getEntityById(id); if (!entity) return jsonResponse({ error: "not_found" }, 404); return jsonResponse({ entity }); }); // ---- Dashboard entity sync — pull tabs from Node-RED, mirror as entities -- app.post("/admin/entities/sync-dashboards", async (event) => { const result = await syncDashboardsFromNodered(deps); if (isHtmxRequest(event)) { return htmlFragment( `
Synced: +${String(result.added)} added, ${String(result.updated)} updated, ${String(result.total)} total.
`, ); } return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); }); } /** * Pull dashboard tabs from the Node-RED runtime and mirror them as `dashboard` * entities. Idempotent: existing entities matched by dashboard_id get name * updates, new tabs get inserted. Tabs that no longer exist are NOT auto- * deleted — admins might still be using a stale layout cell that points to one, * and dashboards are cheap to leave around. */ async function syncDashboardsFromNodered( deps: AdminDeps, ): Promise<{ added: number; updated: number; total: number }> { const tabs = await deps.nodered.listDashboards(); let added = 0; let updated = 0; for (const tab of tabs) { const existing = await deps.repo.getEntityForDashboard(tab.id); if (existing) { if (existing.name !== tab.name) { // Avoid name collisions with non-dashboard entities of the same name. const collision = await deps.repo.getEntityByName(tab.name); const safeName = collision && collision.id !== existing.id ? `${tab.name} (dash ${tab.id.slice(0, 6)})` : tab.name; await deps.repo.updateEntity(existing.id, { name: safeName }); updated += 1; } continue; } // New dashboard tab — insert. let name = tab.name || `Dashboard ${tab.id.slice(0, 6)}`; if (await deps.repo.getEntityByName(name)) { name = `${name} (dash ${tab.id.slice(0, 6)})`; } await deps.repo.createEntity({ name, type: "dashboard", dashboard_id: tab.id, description: tab.hidden ? "hidden tab" : null, }); added += 1; } if (added > 0 || updated > 0) { try { getCoordinator().notifyBundleChanged(); } catch { /* ignore */ } } return { added, updated, total: tabs.length }; }