refactor: migrate all auto-increment PKs to UUIDv7 text IDs

Replace SERIAL/AUTOINCREMENT integer primary keys with UUIDv7 text
IDs across all 15 entity tables (users, api_keys, displays, cameras,
camera_streams, layouts, layout_cells, entities, kiosks, labels,
kiosk_gpio_bindings, event_log, kiosk_logs, audit_log,
camera_event_subscriptions). SetupState keeps id=1 INTEGER singleton.

Changes:
- types.ts: all id fields number->string, all FK fields number->string
- mappers.ts: n(r["id"])->s(r["id"]) for PKs, nn()->sn() for nullable FKs
- repository.ts: import uuidv7, generate IDs before INSERT, remove
  RETURNING id, change all method signatures from number to string
- migrations-pg.ts: SERIAL->TEXT NOT NULL PRIMARY KEY, INTEGER FK->TEXT FK
- bundle.ts: all bundle interface IDs number->string
- pairing.ts, auth.ts: kioskId/userId types number->string
- coordinator-registry.ts: kioskId number->string
- audit.ts: actor_id number->string
- mqtt-bridge.ts: kioskId number->string in publish/subscribe
- All route handlers: Number(getRouterParam)->getRouterParam ?? ""
- admin-pages.tsx: template function params and Map types number->string
- kiosk/src/bundle.rs: flexible serde deserializer that accepts both
  u32 (old) and String (new) IDs for backward compatibility

Fresh PG database -- no data migration needed, just schema changes.
SQLite migrations unchanged (dev-only, recreate DB for UUIDv7).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-26 07:11:45 +02:00
parent 69e51197bf
commit 64f47a9a6b
No known key found for this signature in database
18 changed files with 506 additions and 490 deletions

View file

@ -1,8 +1,10 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct KioskBundle { pub struct KioskBundle {
pub kiosk_id: u32, #[serde(deserialize_with = "de_flexible_id")]
pub kiosk_id: String,
pub kiosk_name: String, pub kiosk_name: String,
/// Legacy single-display field (mirrors `displays[0]`). New code should /// Legacy single-display field (mirrors `displays[0]`). New code should
/// iterate `displays` instead. /// iterate `displays` instead.
@ -31,7 +33,7 @@ impl KioskBundle {
} }
if let Some(d) = &self.display { if let Some(d) = &self.display {
return vec![BundleDisplayWithLayouts { return vec![BundleDisplayWithLayouts {
id: d.id, id: d.id.clone(),
name: d.name.clone(), name: d.name.clone(),
width_px: d.width_px, width_px: d.width_px,
height_px: d.height_px, height_px: d.height_px,
@ -90,7 +92,8 @@ pub struct BundleCell {
pub row_span: u32, pub row_span: u32,
pub col_span: u32, pub col_span: u32,
pub content_type: String, pub content_type: String,
pub camera_id: Option<u32>, #[serde(default, deserialize_with = "de_flexible_id_opt")]
pub camera_id: Option<String>,
pub stream_selector: Option<String>, pub stream_selector: Option<String>,
pub web_url: Option<String>, pub web_url: Option<String>,
pub html_content: Option<String>, pub html_content: Option<String>,

View file

@ -22,7 +22,7 @@ declare module "h3" {
function syntheticApiKeyUser(keyPrefix: string): User { function syntheticApiKeyUser(keyPrefix: string): User {
return { return {
id: 0, id: "",
username: `api:${keyPrefix}`, username: `api:${keyPrefix}`,
password_hash: "", password_hash: "",
role: "admin", role: "admin",

View file

@ -163,9 +163,9 @@ function formValues(v: FormValue): string[] {
return v ? [v] : []; return v ? [v] : [];
} }
function kioskOnvifSoapTransport(kioskId: number) { function kioskOnvifSoapTransport(kioskId: string) {
return async (url: string, action: string, body: string, timeoutMs: number): Promise<string> => { return async (url: string, action: string, body: string, timeoutMs: number): Promise<string> => {
if (!Number.isInteger(kioskId) || kioskId <= 0) { if (!kioskId) {
throw new Error("invalid kiosk selected for discovery"); throw new Error("invalid kiosk selected for discovery");
} }
const response = await getCoordinator().requestKiosk<{ const response = await getCoordinator().requestKiosk<{
@ -209,7 +209,7 @@ async function importDiscoveredCamera(
username: string, username: string,
password: string, password: string,
streams: DiscoverAddStream[], streams: DiscoverAddStream[],
): Promise<number | null> { ): Promise<string | null> {
if (streams.length === 0) return null; if (streams.length === 0) return null;
const main = streams.find((s) => s.role === "main") ?? streams[0]!; const main = streams.find((s) => s.role === "main") ?? streams[0]!;
// Camera row's rtsp_url: full URL with credentials for display / backward compat. // Camera row's rtsp_url: full URL with credentials for display / backward compat.
@ -283,7 +283,7 @@ function cellsOverlap(
} }
interface CellPos { interface CellPos {
id: number; id: string;
row: number; row: number;
col: number; col: number;
row_span: number; row_span: number;
@ -292,12 +292,12 @@ interface CellPos {
async function resolveOverlaps( async function resolveOverlaps(
deps: AdminDeps, deps: AdminDeps,
layoutId: number, layoutId: string,
anchorId: number, anchorId: string,
pushAxis: "row" | "col", pushAxis: "row" | "col",
): Promise<void> { ): Promise<void> {
const all = await deps.repo.layoutCells(layoutId); const all = await deps.repo.layoutCells(layoutId);
const positions = new Map<number, CellPos>(); const positions = new Map<string, CellPos>();
for (const c of all) { 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 }); positions.set(c.id, { id: c.id, row: c.row, col: c.col, row_span: c.row_span, col_span: c.col_span });
} }
@ -353,8 +353,8 @@ async function resolveOverlaps(
async function shiftCellsForExpansion( async function shiftCellsForExpansion(
deps: AdminDeps, deps: AdminDeps,
layoutId: number, layoutId: string,
cellId: number, cellId: string,
direction: "left" | "right" | "above" | "bottom", direction: "left" | "right" | "above" | "bottom",
): Promise<void> { ): Promise<void> {
const cell = await deps.repo.getLayoutCellById(cellId); const cell = await deps.repo.getLayoutCellById(cellId);
@ -379,7 +379,7 @@ async function shiftCellsForExpansion(
async function shiftCellsForInsertion( async function shiftCellsForInsertion(
deps: AdminDeps, deps: AdminDeps,
layoutId: number, layoutId: string,
axis: "row" | "col", axis: "row" | "col",
fromIndex: number, fromIndex: number,
crossStart: number, crossStart: number,
@ -541,8 +541,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/cameras", async (event) => { app.get("/admin/cameras", async (event) => {
const user = event.context.user!; const user = event.context.user!;
const cameras = await deps.repo.listCameras(); const cameras = await deps.repo.listCameras();
const streamCounts = new Map<number, number>(); const streamCounts = new Map<string, number>();
const activeKiosks = new Map<number, number>(); // camera_id → count of kiosks rendering const activeKiosks = new Map<string, number>(); // camera_id → count of kiosks rendering
for (const cam of cameras) { for (const cam of cameras) {
streamCounts.set(cam.id, (await deps.repo.listCameraStreams(cam.id)).length); streamCounts.set(cam.id, (await deps.repo.listCameraStreams(cam.id)).length);
activeKiosks.set(cam.id, (await deps.repo.listKiosksRenderingCamera(cam.id)).length); activeKiosks.set(cam.id, (await deps.repo.listKiosksRenderingCamera(cam.id)).length);
@ -643,7 +643,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
try { try {
const soapTransport = runner.startsWith("kiosk:") const soapTransport = runner.startsWith("kiosk:")
? kioskOnvifSoapTransport(Number(runner.slice("kiosk:".length))) ? kioskOnvifSoapTransport(runner.slice("kiosk:".length))
: undefined; : undefined;
const cameras = await onvifDiscover({ host, port, username, password, soapTransport }); const cameras = await onvifDiscover({ host, port, username, password, soapTransport });
return htmlPage(CameraDiscoverResultsPage({ return htmlPage(CameraDiscoverResultsPage({
@ -740,11 +740,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
errors.push("Select an entity type."); errors.push("Select an entity type.");
} }
let cameraId: number | null = null; let cameraId: string | null = null;
let htmlContent: string | null = null; let htmlContent: string | null = null;
let webUrl: string | null = null; let webUrl: string | null = null;
if (type === "camera") { if (type === "camera") {
cameraId = body?.["camera_id"] ? Number(body["camera_id"]) : null; cameraId = body?.["camera_id"] ? String(body["camera_id"] ?? "") : null;
if (!cameraId) errors.push("Pick a camera."); if (!cameraId) errors.push("Pick a camera.");
} else if (type === "html") { } else if (type === "html") {
htmlContent = body?.["html_content"] ?? null; htmlContent = body?.["html_content"] ?? null;
@ -776,7 +776,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/entities/:id", async (event) => { app.get("/admin/entities/:id", async (event) => {
const user = event.context.user!; const user = event.context.user!;
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const ent = await deps.repo.getEntityById(id); const ent = await deps.repo.getEntityById(id);
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
return htmlPage(EntityEditPage({ return htmlPage(EntityEditPage({
@ -787,14 +787,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/entities/:id", async (event) => { app.post("/admin/entities/:id", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const ent = await deps.repo.getEntityById(id); const ent = await deps.repo.getEntityById(id);
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const patch: { const patch: {
name?: string; name?: string;
description?: string | null; description?: string | null;
camera_id?: number | null; camera_id?: string | null;
html_content?: string | null; html_content?: string | null;
web_url?: string | null; web_url?: string | null;
} = { } = {
@ -802,7 +802,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
description: (body?.["description"] ?? "").trim() || null, description: (body?.["description"] ?? "").trim() || null,
}; };
if (ent.type === "camera") { if (ent.type === "camera") {
patch.camera_id = body?.["camera_id"] ? Number(body["camera_id"]) : null; patch.camera_id = body?.["camera_id"] ? String(body["camera_id"] ?? "") : null;
} else if (ent.type === "html") { } else if (ent.type === "html") {
patch.html_content = body?.["html_content"] ?? null; patch.html_content = body?.["html_content"] ?? null;
} else if (ent.type === "web") { } else if (ent.type === "web") {
@ -814,7 +814,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/entities/:id/delete", async (event) => { app.post("/admin/entities/:id/delete", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
await deps.repo.deleteEntity(id); await deps.repo.deleteEntity(id);
notifyKiosks(); notifyKiosks();
return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
@ -825,7 +825,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// no kiosk currently has the camera in its active layout (or every kiosk // no kiosk currently has the camera in its active layout (or every kiosk
// attempt times out). Used by the EntityEditPage "Test" preview. // attempt times out). Used by the EntityEditPage "Test" preview.
app.get("/admin/entities/:id/snapshot", async (event) => { app.get("/admin/entities/:id/snapshot", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const ent = await deps.repo.getEntityById(id); const ent = await deps.repo.getEntityById(id);
if (!ent || ent.type !== "camera" || ent.camera_id == null) { if (!ent || ent.type !== "camera" || ent.camera_id == null) {
return new Response("Not a camera entity", { status: 404 }); return new Response("Not a camera entity", { status: 404 });
@ -902,7 +902,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const labelsStr = (body?.["initial_labels"] ?? "").trim(); const labelsStr = (body?.["initial_labels"] ?? "").trim();
const initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined; const initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
const replaceIdRaw = (body?.["replace_kiosk_id"] ?? "").trim(); const replaceIdRaw = (body?.["replace_kiosk_id"] ?? "").trim();
const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined; const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? replaceIdRaw : undefined;
const force = body?.["force"] === "1"; const force = body?.["force"] === "1";
try { try {
@ -939,7 +939,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const user = event.context.user!; const user = event.context.user!;
const layouts = await deps.repo.listLayouts(); const layouts = await deps.repo.listLayouts();
// For each layout, how many displays use it (for the list view). // For each layout, how many displays use it (for the list view).
const displayCounts = new Map<number, number>(); const displayCounts = new Map<string, number>();
for (const l of layouts) { for (const l of layouts) {
displayCounts.set(l.id, (await deps.repo.listDisplaysForLayout(l.id)).length); displayCounts.set(l.id, (await deps.repo.listDisplaysForLayout(l.id)).length);
} }
@ -986,7 +986,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/layouts/:id", async (event) => { app.get("/admin/layouts/:id", async (event) => {
const user = event.context.user!; const user = event.context.user!;
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const layout = await deps.repo.getLayoutById(id); const layout = await deps.repo.getLayoutById(id);
if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } }); if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
const cells = await deps.repo.layoutCells(id); const cells = await deps.repo.layoutCells(id);
@ -1005,7 +1005,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.post("/admin/layouts/:id", async (event) => { app.post("/admin/layouts/:id", async (event) => {
event.context.obs?.log.info("layout update {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" }); event.context.obs?.log.info("layout update {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const coolingStr = body?.["cooling_timeout_seconds"] ?? ""; const coolingStr = body?.["cooling_timeout_seconds"] ?? "";
const coolingTimeout = coolingStr.trim() === "" ? null : parseInt(coolingStr, 10); const coolingTimeout = coolingStr.trim() === "" ? null : parseInt(coolingStr, 10);
@ -1026,7 +1026,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// For htmx requests (hx-request header), returns the grid fragment; otherwise // For htmx requests (hx-request header), returns the grid fragment; otherwise
// returns a 302 to the layout edit page. // returns a 302 to the layout edit page.
app.post("/admin/layouts/:id/cells", async (event) => { app.post("/admin/layouts/:id/cells", async (event) => {
const layoutId = Number(getRouterParam(event, "id")); const layoutId = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string | number | { row: number; col: number }>>(event); const body = await readBody<Record<string, string | number | { row: number; col: number }>>(event);
let row = 0; let row = 0;
@ -1036,7 +1036,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const direction = typeof body?.["direction"] === "string" ? (body["direction"] as string) : ""; const direction = typeof body?.["direction"] === "string" ? (body["direction"] as string) : "";
if (afterCellIdRaw && direction) { if (afterCellIdRaw && direction) {
const afterId = Number(afterCellIdRaw); const afterId = String(afterCellIdRaw);
const cells = await deps.repo.layoutCells(layoutId); const cells = await deps.repo.layoutCells(layoutId);
const ref = cells.find((c) => c.id === afterId); const ref = cells.find((c) => c.id === afterId);
if (!ref) { if (!ref) {
@ -1097,8 +1097,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// GET a single cell in read mode (used by htmx Cancel button in inline edit). // GET a single cell in read mode (used by htmx Cancel button in inline edit).
app.get("/admin/layouts/:id/cells/:cellId", async (event) => { app.get("/admin/layouts/:id/cells/:cellId", async (event) => {
const layoutId = Number(getRouterParam(event, "id")); const layoutId = (getRouterParam(event, "id") ?? "");
const cellId = Number(getRouterParam(event, "cellId")); const cellId = (getRouterParam(event, "cellId") ?? "");
const cell = await deps.repo.getLayoutCellById(cellId); const cell = await deps.repo.getLayoutCellById(cellId);
if (!cell || cell.layout_id !== layoutId) { if (!cell || cell.layout_id !== layoutId) {
return new Response("Not Found", { status: 404 }); return new Response("Not Found", { status: 404 });
@ -1110,8 +1110,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// GET a single cell in edit mode (htmx swap target for cell click). // GET a single cell in edit mode (htmx swap target for cell click).
app.get("/admin/layouts/:id/cells/:cellId/edit", async (event) => { app.get("/admin/layouts/:id/cells/:cellId/edit", async (event) => {
const layoutId = Number(getRouterParam(event, "id")); const layoutId = (getRouterParam(event, "id") ?? "");
const cellId = Number(getRouterParam(event, "cellId")); const cellId = (getRouterParam(event, "cellId") ?? "");
const cell = await deps.repo.getLayoutCellById(cellId); const cell = await deps.repo.getLayoutCellById(cellId);
if (!cell || cell.layout_id !== layoutId) { if (!cell || cell.layout_id !== layoutId) {
return new Response("Not Found", { status: 404 }); return new Response("Not Found", { status: 404 });
@ -1124,14 +1124,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// Update a cell's entity binding + dimensions. Legacy content_type/web/html // Update a cell's entity binding + dimensions. Legacy content_type/web/html
// columns are managed by assignCellEntity for bundle compatibility. // columns are managed by assignCellEntity for bundle compatibility.
app.post("/admin/layouts/:id/cells/:cellId", async (event) => { app.post("/admin/layouts/:id/cells/:cellId", async (event) => {
const layoutId = Number(getRouterParam(event, "id")); const layoutId = (getRouterParam(event, "id") ?? "");
const cellId = Number(getRouterParam(event, "cellId")); const cellId = (getRouterParam(event, "cellId") ?? "");
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const entityIdRaw = body?.["entity_id"]; const entityIdRaw = body?.["entity_id"];
const entityId = const entityId =
entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null; entityIdRaw && String(entityIdRaw).trim() !== "" ? String(entityIdRaw) : null;
await deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : null); await deps.repo.assignCellEntity(cellId, entityId != null && entityId !== "" ? entityId : null);
// stream_selector + spans + fit are still settable on the cell. // stream_selector + spans + fit are still settable on the cell.
const dimsPatch: Record<string, unknown> = {}; const dimsPatch: Record<string, unknown> = {};
@ -1221,8 +1221,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// Resize a cell by ±1 on row_span or col_span. Returns the grid fragment. // 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) => { app.post("/admin/layouts/:id/cells/:cellId/resize", async (event) => {
const layoutId = Number(getRouterParam(event, "id")); const layoutId = (getRouterParam(event, "id") ?? "");
const cellId = Number(getRouterParam(event, "cellId")); const cellId = (getRouterParam(event, "cellId") ?? "");
const body = await readBody<Record<string, string | number>>(event); const body = await readBody<Record<string, string | number>>(event);
const dim = String(body?.["dim"] ?? ""); const dim = String(body?.["dim"] ?? "");
const delta = Number(body?.["delta"] ?? 0) || 0; const delta = Number(body?.["delta"] ?? 0) || 0;
@ -1257,8 +1257,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// Visual editor: drag-to-move a cell to a new grid position. // Visual editor: drag-to-move a cell to a new grid position.
app.post("/admin/layouts/:id/cells/:cellId/move", async (event) => { app.post("/admin/layouts/:id/cells/:cellId/move", async (event) => {
const layoutId = Number(getRouterParam(event, "id")); const layoutId = (getRouterParam(event, "id") ?? "");
const cellId = Number(getRouterParam(event, "cellId")); const cellId = (getRouterParam(event, "cellId") ?? "");
const body = await readBody<{ row: number; col: number }>(event); const body = await readBody<{ row: number; col: number }>(event);
const row = Number(body?.row ?? 0); const row = Number(body?.row ?? 0);
const col = Number(body?.col ?? 0); const col = Number(body?.col ?? 0);
@ -1270,8 +1270,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/layouts/:id/cells/:cellId/delete", async (event) => { app.post("/admin/layouts/:id/cells/:cellId/delete", async (event) => {
const layoutId = Number(getRouterParam(event, "id")); const layoutId = (getRouterParam(event, "id") ?? "");
const cellId = Number(getRouterParam(event, "cellId")); const cellId = (getRouterParam(event, "cellId") ?? "");
await deps.repo.deleteLayoutCell(cellId); await deps.repo.deleteLayoutCell(cellId);
notifyKiosks(); notifyKiosks();
if (isHtmxRequest(event)) { if (isHtmxRequest(event)) {
@ -1284,7 +1284,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/layouts/:id/clone", async (event) => { app.post("/admin/layouts/:id/clone", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const clone = await deps.repo.cloneLayout(id); const clone = await deps.repo.cloneLayout(id);
notifyKiosks(); notifyKiosks();
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${clone.id}` } }); return new Response(null, { status: 302, headers: { location: `/admin/layouts/${clone.id}` } });
@ -1292,7 +1292,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.post("/admin/layouts/:id/delete", async (event) => { app.post("/admin/layouts/:id/delete", async (event) => {
event.context.obs?.log.info("layout delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" }); event.context.obs?.log.info("layout delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
await deps.repo.deleteLayout(id); await deps.repo.deleteLayout(id);
notifyKiosks(); notifyKiosks();
return new Response(null, { status: 302, headers: { location: "/admin/layouts" } }); return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
@ -1308,7 +1308,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/displays/:id", async (event) => { app.get("/admin/displays/:id", async (event) => {
const user = event.context.user!; const user = event.context.user!;
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const display = await deps.repo.getDisplayById(id); const display = await deps.repo.getDisplayById(id);
if (!display) return new Response(null, { status: 302, headers: { location: "/admin/displays" } }); if (!display) return new Response(null, { status: 302, headers: { location: "/admin/displays" } });
const attachedLayouts = await deps.repo.listLayoutsForDisplay(id); const attachedLayouts = await deps.repo.listLayoutsForDisplay(id);
@ -1325,13 +1325,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/displays/:id", async (event) => { app.post("/admin/displays/:id", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const defaultLayoutIdRaw = body?.["default_layout_id"]; const defaultLayoutIdRaw = body?.["default_layout_id"];
const defaultLayoutId = defaultLayoutIdRaw ? Number(defaultLayoutIdRaw) : null; const defaultLayoutId = defaultLayoutIdRaw ? String(defaultLayoutIdRaw) : null;
// Validate default_layout_id is actually attached to this display. // Validate default_layout_id is actually attached to this display.
let validatedDefault: number | null = defaultLayoutId; let validatedDefault: string | null = defaultLayoutId;
if (defaultLayoutId != null) { if (defaultLayoutId != null) {
const attached = await deps.repo.listLayoutsForDisplay(id); const attached = await deps.repo.listLayoutsForDisplay(id);
if (!attached.some((l) => l.id === defaultLayoutId)) { if (!attached.some((l) => l.id === defaultLayoutId)) {
@ -1353,7 +1353,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
// Render the attached + available layouts region for a display. // Render the attached + available layouts region for a display.
const renderDisplayLayoutsFragment = async (displayId: number): Promise<Response> => { const renderDisplayLayoutsFragment = async (displayId: string): Promise<Response> => {
const display = await deps.repo.getDisplayById(displayId); const display = await deps.repo.getDisplayById(displayId);
const attached = await deps.repo.listLayoutsForDisplay(displayId); const attached = await deps.repo.listLayoutsForDisplay(displayId);
const attachedIds = new Set(attached.map((l) => l.id)); const attachedIds = new Set(attached.map((l) => l.id));
@ -1366,10 +1366,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// Attach a layout to a display. // Attach a layout to a display.
app.post("/admin/displays/:id/layouts", async (event) => { app.post("/admin/displays/:id/layouts", async (event) => {
const displayId = Number(getRouterParam(event, "id")); const displayId = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const layoutId = body?.["layout_id"] ? Number(body["layout_id"]) : null; const layoutId = body?.["layout_id"] ? String(body["layout_id"]) : null;
if (layoutId && Number.isFinite(layoutId)) { if (layoutId && layoutId) {
await deps.repo.attachLayoutToDisplay(displayId, layoutId); await deps.repo.attachLayoutToDisplay(displayId, layoutId);
notifyKiosks(); notifyKiosks();
} }
@ -1381,8 +1381,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// Detach a layout from a display. // Detach a layout from a display.
app.post("/admin/displays/:id/layouts/:layoutId/remove", async (event) => { app.post("/admin/displays/:id/layouts/:layoutId/remove", async (event) => {
const displayId = Number(getRouterParam(event, "id")); const displayId = (getRouterParam(event, "id") ?? "");
const layoutId = Number(getRouterParam(event, "layoutId")); const layoutId = (getRouterParam(event, "layoutId") ?? "");
await deps.repo.detachLayoutFromDisplay(displayId, layoutId); await deps.repo.detachLayoutFromDisplay(displayId, layoutId);
notifyKiosks(); notifyKiosks();
if (isHtmxRequest(event)) { if (isHtmxRequest(event)) {
@ -1412,7 +1412,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/labels/:id/delete", async (event) => { app.post("/admin/labels/:id/delete", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
await deps.repo.deleteLabel(id); await deps.repo.deleteLabel(id);
return new Response(null, { status: 302, headers: { location: "/admin/labels" } }); return new Response(null, { status: 302, headers: { location: "/admin/labels" } });
}); });
@ -1421,7 +1421,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/cameras/:id", async (event) => { app.get("/admin/cameras/:id", async (event) => {
const user = event.context.user!; const user = event.context.user!;
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const camera = await deps.repo.getCameraById(id); const camera = await deps.repo.getCameraById(id);
if (!camera) return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); if (!camera) return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
@ -1464,7 +1464,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.post("/admin/cameras/:id", async (event) => { app.post("/admin/cameras/:id", async (event) => {
event.context.obs?.log.info("camera update {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" }); event.context.obs?.log.info("camera update {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const cam = await deps.repo.getCameraById(id); const cam = await deps.repo.getCameraById(id);
if (cam?.type === "cloud") { if (cam?.type === "cloud") {
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
@ -1541,10 +1541,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/cameras/:id/labels", async (event) => { app.post("/admin/cameras/:id/labels", async (event) => {
const camId = Number(getRouterParam(event, "id")); const camId = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase(); const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase();
let labelId = body?.["label_id"] ? Number(body["label_id"]) : null; let labelId = body?.["label_id"] ? String(body["label_id"] ?? "") : null;
if (newLabel) { if (newLabel) {
const label = await deps.repo.ensureLabel(newLabel); const label = await deps.repo.ensureLabel(newLabel);
@ -1560,9 +1560,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/cameras/:id/labels/remove", async (event) => { app.post("/admin/cameras/:id/labels/remove", async (event) => {
const camId = Number(getRouterParam(event, "id")); const camId = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const labelId = Number(body?.["label_id"]); const labelId = String(body?.["label_id"] ?? "");
await deps.repo.detachCameraLabel(camId, labelId); await deps.repo.detachCameraLabel(camId, labelId);
if (isHtmxRequest(event)) { if (isHtmxRequest(event)) {
return htmlFragment(renderCameraLabels(camId, await deps.repo.cameraLabelIds(camId), await deps.repo.listLabels())); return htmlFragment(renderCameraLabels(camId, await deps.repo.cameraLabelIds(camId), await deps.repo.listLabels()));
@ -1573,7 +1573,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// Refresh supported ONVIF event topics from the camera. // Refresh supported ONVIF event topics from the camera.
// MERGE: new topics are added to the existing list, never removed. // MERGE: new topics are added to the existing list, never removed.
app.post("/admin/cameras/:id/refresh-events", async (event) => { app.post("/admin/cameras/:id/refresh-events", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const cam = await deps.repo.getCameraById(id); const cam = await deps.repo.getCameraById(id);
if (!cam || cam.type !== "onvif" || !cam.onvif_host) { if (!cam || cam.type !== "onvif" || !cam.onvif_host) {
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
@ -1591,7 +1591,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
runner = online ? `kiosk:${online.id}` : "server"; runner = online ? `kiosk:${online.id}` : "server";
} }
const soapTransport = runner.startsWith("kiosk:") const soapTransport = runner.startsWith("kiosk:")
? kioskOnvifSoapTransport(Number(runner.slice("kiosk:".length))) ? kioskOnvifSoapTransport(runner.slice("kiosk:".length))
: undefined; : undefined;
try { try {
const discoveredTopics = await onvifGetEventProperties({ const discoveredTopics = await onvifGetEventProperties({
@ -1618,7 +1618,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// Subscribe to all inactive event topics for this camera. // Subscribe to all inactive event topics for this camera.
app.post("/admin/cameras/:id/subscribe-events", async (event) => { app.post("/admin/cameras/:id/subscribe-events", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const cam = await deps.repo.getCameraById(id); const cam = await deps.repo.getCameraById(id);
if (!cam) { if (!cam) {
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
@ -1629,7 +1629,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.post("/admin/cameras/:id/delete", async (event) => { app.post("/admin/cameras/:id/delete", async (event) => {
event.context.obs?.log.info("camera delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" }); event.context.obs?.log.info("camera delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
await deps.repo.deleteCamera(id); await deps.repo.deleteCamera(id);
notifyKiosks(); notifyKiosks();
deps.nodered.forward("camera.changed", { camera_id: id, event: "deleted", source: "server" }); deps.nodered.forward("camera.changed", { camera_id: id, event: "deleted", source: "server" });
@ -1643,7 +1643,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}; };
const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
app.get("/admin/cameras/:id/events", async (event) => { app.get("/admin/cameras/:id/events", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const { events } = await deps.repo.queryEvents({ const { events } = await deps.repo.queryEvents({
camera_id: id, camera_id: id,
limit: 20, limit: 20,
@ -1672,7 +1672,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/kiosks/:id", async (event) => { app.get("/admin/kiosks/:id", async (event) => {
const user = event.context.user!; const user = event.context.user!;
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const kiosk = await deps.repo.getKioskById(id); const kiosk = await deps.repo.getKioskById(id);
if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
const kioskLabels = (await deps.repo.listKioskLabels(id)).map((kl) => ({ const kioskLabels = (await deps.repo.listKioskLabels(id)).map((kl) => ({
@ -1709,7 +1709,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// ---- GPIO bindings ---------------------------------------------------- // ---- GPIO bindings ----------------------------------------------------
app.post("/admin/kiosks/:id/gpio", async (event) => { app.post("/admin/kiosks/:id/gpio", async (event) => {
const kioskId = Number(getRouterParam(event, "id")); const kioskId = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const pin = Number(body?.["pin"]); const pin = Number(body?.["pin"]);
const direction = (body?.["direction"] ?? "in") === "out" ? "out" : "in"; const direction = (body?.["direction"] ?? "in") === "out" ? "out" : "in";
@ -1735,8 +1735,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/kiosks/:id/gpio/:bindingId/delete", async (event) => { app.post("/admin/kiosks/:id/gpio/:bindingId/delete", async (event) => {
const kioskId = Number(getRouterParam(event, "id")); const kioskId = (getRouterParam(event, "id") ?? "");
const bindingId = Number(getRouterParam(event, "bindingId")); const bindingId = (getRouterParam(event, "bindingId") ?? "");
await deps.repo.deleteGpioBinding(bindingId); await deps.repo.deleteGpioBinding(bindingId);
notifyKiosks(); notifyKiosks();
if (isHtmxRequest(event)) { if (isHtmxRequest(event)) {
@ -1748,7 +1748,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/kiosks/:id", async (event) => { app.post("/admin/kiosks/:id", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const kiosk = await deps.repo.getKioskById(id); const kiosk = await deps.repo.getKioskById(id);
await deps.repo.updateKiosk(id, { await deps.repo.updateKiosk(id, {
@ -1776,7 +1776,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// cluster key it received at pairing), then bumps managed_config_version // cluster key it received at pairing), then bumps managed_config_version
// so the next heartbeat ships it to the kiosk. // so the next heartbeat ships it to the kiosk.
app.post("/admin/kiosks/:id/managed-config", async (event) => { app.post("/admin/kiosks/:id/managed-config", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const kiosk = await deps.repo.getKioskById(id); const kiosk = await deps.repo.getKioskById(id);
if (!kiosk) throw new Error("kiosk not found"); if (!kiosk) throw new Error("kiosk not found");
if (!kiosk.managed_image) throw new Error("kiosk is not running a managed image"); if (!kiosk.managed_image) throw new Error("kiosk is not running a managed image");
@ -1845,11 +1845,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/kiosks/:id/labels", async (event) => { app.post("/admin/kiosks/:id/labels", async (event) => {
const kioskId = Number(getRouterParam(event, "id")); const kioskId = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase(); const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase();
const role = (body?.["role"] ?? "consume") as "consume" | "operate"; const role = (body?.["role"] ?? "consume") as "consume" | "operate";
let labelId = body?.["label_id"] ? Number(body["label_id"]) : null; let labelId = body?.["label_id"] ? String(body["label_id"] ?? "") : null;
if (newLabel) { if (newLabel) {
const label = await deps.repo.ensureLabel(newLabel); const label = await deps.repo.ensureLabel(newLabel);
@ -1870,9 +1870,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/kiosks/:id/labels/remove", async (event) => { app.post("/admin/kiosks/:id/labels/remove", async (event) => {
const kioskId = Number(getRouterParam(event, "id")); const kioskId = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const labelId = Number(body?.["label_id"]); const labelId = String(body?.["label_id"] ?? "");
await deps.repo.detachKioskLabel(kioskId, labelId); await deps.repo.detachKioskLabel(kioskId, labelId);
if (isHtmxRequest(event)) { if (isHtmxRequest(event)) {
const kioskLabels = (await deps.repo.listKioskLabels(kioskId)).map((kl) => ({ const kioskLabels = (await deps.repo.listKioskLabels(kioskId)).map((kl) => ({
@ -1887,7 +1887,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.post("/admin/kiosks/:id/delete", async (event) => { app.post("/admin/kiosks/:id/delete", async (event) => {
event.context.obs?.log.info("kiosk delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" }); event.context.obs?.log.info("kiosk delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
await deps.repo.deleteKiosk(id); await deps.repo.deleteKiosk(id);
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
}); });
@ -1897,7 +1897,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// /ws/admin/debug/:kioskId and render output. The WS connection is // /ws/admin/debug/:kioskId and render output. The WS connection is
// authenticated via the admin's API key. // authenticated via the admin's API key.
app.get("/admin/kiosks/:id/logs", async (event) => { app.get("/admin/kiosks/:id/logs", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const kiosk = await deps.repo.getKioskById(id); const kiosk = await deps.repo.getKioskById(id);
if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
const user = event.context.user!; const user = event.context.user!;
@ -1945,7 +1945,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.get("/admin/kiosks/:id/terminal", async (event) => { app.get("/admin/kiosks/:id/terminal", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const kiosk = await deps.repo.getKioskById(id); const kiosk = await deps.repo.getKioskById(id);
if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
// WS auth: browser sends session cookie automatically on WS upgrade. // WS auth: browser sends session cookie automatically on WS upgrade.
@ -2076,7 +2076,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
// ---- Layout switch ---------------------------------------------------- // ---- Layout switch ----------------------------------------------------
const emitLayoutChanged = async (displayId: number | null, kioskId: number | null, layoutId: number) => { const emitLayoutChanged = async (displayId: string | null, kioskId: string | null, layoutId: string) => {
const layout = await deps.repo.getLayoutById(layoutId); const layout = await deps.repo.getLayoutById(layoutId);
deps.nodered.forward("layout.changed", { deps.nodered.forward("layout.changed", {
display_id: displayId, display_id: displayId,
@ -2088,13 +2088,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}; };
const displayLayoutSwitch = async (event: any) => { const displayLayoutSwitch = async (event: any) => {
const displayId = Number(getRouterParam(event, "displayId")); const displayId = (getRouterParam(event, "displayId") ?? "");
let layoutId = Number(getRouterParam(event, "layoutId")); let layoutId = getRouterParam(event, "layoutId") ?? "";
if (!Number.isFinite(layoutId) || layoutId <= 0) { if (!layoutId) {
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
layoutId = Number(body?.["layout_id"]); layoutId = String(body?.["layout_id"] ?? "");
} }
if (Number.isFinite(displayId) && Number.isFinite(layoutId)) { if (displayId && layoutId) {
const display = await deps.repo.getDisplayById(displayId); const display = await deps.repo.getDisplayById(displayId);
const attached = await deps.repo.listLayoutsForDisplay(displayId); const attached = await deps.repo.listLayoutsForDisplay(displayId);
const isAttached = attached.some((l) => l.id === layoutId); const isAttached = attached.some((l) => l.id === layoutId);
@ -2114,7 +2114,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch); app.get("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch);
const displayPower = async (event: any, state: "on" | "standby") => { const displayPower = async (event: any, state: "on" | "standby") => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const display = await deps.repo.getDisplayById(id); const display = await deps.repo.getDisplayById(id);
if (display?.kiosk_id) { if (display?.kiosk_id) {
getCoordinator().sendToKiosk(display.kiosk_id, { getCoordinator().sendToKiosk(display.kiosk_id, {
@ -2144,7 +2144,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
// ---- CEC power commands ----------------------------------------------- // ---- CEC power commands -----------------------------------------------
const emitDisplayPower = async (kioskId: number, state: "on" | "standby") => { const emitDisplayPower = async (kioskId: string, state: "on" | "standby") => {
const displays = await deps.repo.listDisplaysForKiosk(kioskId); const displays = await deps.repo.listDisplaysForKiosk(kioskId);
const displayId = displays[0]?.id ?? null; const displayId = displays[0]?.id ?? null;
const actual = state === "on" ? "awake" : "standby"; const actual = state === "on" ? "awake" : "standby";
@ -2164,7 +2164,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}; };
app.post("/admin/kiosks/:id/power/standby", async (event) => { app.post("/admin/kiosks/:id/power/standby", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
getCoordinator().sendToKiosk(id, { type: "standby" }); getCoordinator().sendToKiosk(id, { type: "standby" });
await emitDisplayPower(id, "standby"); await emitDisplayPower(id, "standby");
await audit(deps.repo, event as any, "display.standby", { resource_type: "kiosk", resource_id: id }); await audit(deps.repo, event as any, "display.standby", { resource_type: "kiosk", resource_id: id });
@ -2172,7 +2172,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/kiosks/:id/power/wake", async (event) => { app.post("/admin/kiosks/:id/power/wake", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
getCoordinator().sendToKiosk(id, { type: "wake" }); getCoordinator().sendToKiosk(id, { type: "wake" });
await emitDisplayPower(id, "on"); await emitDisplayPower(id, "on");
await audit(deps.repo, event as any, "display.wake", { resource_type: "kiosk", resource_id: id }); await audit(deps.repo, event as any, "display.wake", { resource_type: "kiosk", resource_id: id });
@ -2181,7 +2181,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// ---- Fan control ------------------------------------------------------ // ---- Fan control ------------------------------------------------------
app.post("/admin/kiosks/:id/fan", async (event) => { app.post("/admin/kiosks/:id/fan", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const mode = body?.["mode"]; const mode = body?.["mode"];
if (mode === "auto") { if (mode === "auto") {
@ -2216,7 +2216,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.get("/api/admin/cameras/:id", async (event) => { app.get("/api/admin/cameras/:id", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const cam = await deps.repo.getCameraById(id); const cam = await deps.repo.getCameraById(id);
if (!cam) return jsonResponse({ error: "not_found" }, 404); if (!cam) return jsonResponse({ error: "not_found" }, 404);
const streams = await deps.repo.listCameraStreams(id); const streams = await deps.repo.listCameraStreams(id);
@ -2231,7 +2231,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.get("/api/admin/displays/:id", async (event) => { app.get("/api/admin/displays/:id", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const display = await deps.repo.getDisplayById(id); const display = await deps.repo.getDisplayById(id);
if (!display) return jsonResponse({ error: "not_found" }, 404); if (!display) return jsonResponse({ error: "not_found" }, 404);
const attachedLayouts = await deps.repo.listLayoutsForDisplay(id); const attachedLayouts = await deps.repo.listLayoutsForDisplay(id);
@ -2258,7 +2258,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.get("/api/admin/kiosks/:id", async (event) => { app.get("/api/admin/kiosks/:id", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const kiosk = await deps.repo.getKioskById(id); const kiosk = await deps.repo.getKioskById(id);
if (!kiosk) return jsonResponse({ error: "not_found" }, 404); if (!kiosk) return jsonResponse({ error: "not_found" }, 404);
const displays = await deps.repo.listDisplaysForKiosk(id); const displays = await deps.repo.listDisplaysForKiosk(id);
@ -2276,7 +2276,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.get("/api/admin/layouts/:id", async (event) => { app.get("/api/admin/layouts/:id", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const layout = await deps.repo.getLayoutById(id); const layout = await deps.repo.getLayoutById(id);
if (!layout) return jsonResponse({ error: "not_found" }, 404); if (!layout) return jsonResponse({ error: "not_found" }, 404);
const cells = await deps.repo.layoutCells(id); const cells = await deps.repo.layoutCells(id);
@ -2290,7 +2290,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.get("/api/admin/entities/:id", async (event) => { app.get("/api/admin/entities/:id", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const entity = await deps.repo.getEntityById(id); const entity = await deps.repo.getEntityById(id);
if (!entity) return jsonResponse({ error: "not_found" }, 404); if (!entity) return jsonResponse({ error: "not_found" }, 404);
return jsonResponse({ entity }); return jsonResponse({ entity });
@ -2302,11 +2302,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// across all set ops. Returns the post-mutation entity. // across all set ops. Returns the post-mutation entity.
app.post("/api/admin/displays/:id/default-layout", async (event) => { app.post("/api/admin/displays/:id/default-layout", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const body = (await readBody<Record<string, unknown>>(event)) ?? {}; const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const raw = body["value"] ?? body["default_layout_id"]; const raw = body["value"] ?? body["default_layout_id"];
const layoutId = raw == null || raw === "" ? null : Number(raw); const layoutId = raw == null || raw === "" ? null : String(raw);
if (raw != null && raw !== "" && !Number.isFinite(layoutId)) { if (raw != null && raw !== "" && !layoutId) {
return jsonResponse({ error: "invalid_value" }, 400); return jsonResponse({ error: "invalid_value" }, 400);
} }
if (layoutId != null) { if (layoutId != null) {
@ -2322,7 +2322,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/api/admin/kiosks/:id/enabled", async (event) => { app.post("/api/admin/kiosks/:id/enabled", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const body = (await readBody<Record<string, unknown>>(event)) ?? {}; const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const enabled = Boolean(body["value"] ?? body["enabled"]); const enabled = Boolean(body["value"] ?? body["enabled"]);
await deps.repo.updateKiosk(id, { enabled } as any); await deps.repo.updateKiosk(id, { enabled } as any);
@ -2332,7 +2332,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/api/admin/cameras/:id/enabled", async (event) => { app.post("/api/admin/cameras/:id/enabled", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const body = (await readBody<Record<string, unknown>>(event)) ?? {}; const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const enabled = Boolean(body["value"] ?? body["enabled"]); const enabled = Boolean(body["value"] ?? body["enabled"]);
await deps.repo.updateCamera(id, { enabled } as any); await deps.repo.updateCamera(id, { enabled } as any);
@ -2344,7 +2344,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/api/admin/layouts/:id/priority", async (event) => { app.post("/api/admin/layouts/:id/priority", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const body = (await readBody<Record<string, unknown>>(event)) ?? {}; const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const value = String(body["value"] ?? body["priority"] ?? "").toLowerCase(); const value = String(body["value"] ?? body["priority"] ?? "").toLowerCase();
if (value !== "hot" && value !== "normal" && value !== "cold") { if (value !== "hot" && value !== "normal" && value !== "cold") {
@ -2358,7 +2358,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/api/admin/entities/:id/name", async (event) => { app.post("/api/admin/entities/:id/name", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const body = (await readBody<Record<string, unknown>>(event)) ?? {}; const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const name = String(body["value"] ?? body["name"] ?? "").trim(); const name = String(body["value"] ?? body["name"] ?? "").trim();
if (!name || name.length > 128) { if (!name || name.length > 128) {

View file

@ -153,7 +153,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
// ---- Per-kiosk firmware settings ---------------------------------------- // ---- Per-kiosk firmware settings ----------------------------------------
// POST channel + target_version (used by KioskFirmwarePanel form) // POST channel + target_version (used by KioskFirmwarePanel form)
app.post("/admin/kiosks/:id/firmware", async (event) => { app.post("/admin/kiosks/:id/firmware", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel; const channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel;
const targetRaw = (body?.["target_version"] ?? "").trim(); const targetRaw = (body?.["target_version"] ?? "").trim();
@ -176,7 +176,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
// and pulls /api/kiosk/firmware/check immediately. The actual download // and pulls /api/kiosk/firmware/check immediately. The actual download
// happens kiosk-side over the existing kiosk_key channel. // happens kiosk-side over the existing kiosk_key channel.
app.post("/admin/kiosks/:id/firmware/push", (event) => { app.post("/admin/kiosks/:id/firmware/push", (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const dispatched = getCoordinator().sendToKiosk(id, { type: "firmware_check" }); const dispatched = getCoordinator().sendToKiosk(id, { type: "firmware_check" });
return { ok: true, dispatched }; return { ok: true, dispatched };
}); });
@ -204,10 +204,10 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" }); if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" });
const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100); const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100);
const targetsRaw = body?.["target_kiosk_ids"]; const targetsRaw = body?.["target_kiosk_ids"];
const targets: number[] = Array.isArray(targetsRaw) const targets: string[] = Array.isArray(targetsRaw)
? targetsRaw.map((s) => Number(s)).filter((n) => Number.isFinite(n)) ? targetsRaw.map((s) => String(s)).filter((s) => s !== "")
: typeof targetsRaw === "string" && targetsRaw : typeof targetsRaw === "string" && targetsRaw
? targetsRaw.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n)) ? targetsRaw.split(",").map((s) => s.trim()).filter((s) => s !== "")
: []; : [];
const user = event.context.user!; const user = event.context.user!;
const rollout = await deps.repo.createFirmwareRollout({ const rollout = await deps.repo.createFirmwareRollout({

View file

@ -44,7 +44,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
// ---- Per-kiosk OS-update settings --------------------------------------- // ---- Per-kiosk OS-update settings ---------------------------------------
app.post("/admin/kiosks/:id/os-update", async (event) => { app.post("/admin/kiosks/:id/os-update", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel; const channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel;
const targetRaw = (body?.["target_version"] ?? "").trim(); const targetRaw = (body?.["target_version"] ?? "").trim();
@ -65,7 +65,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
// Push OS update now: server pings the kiosk via WS coordinator. // Push OS update now: server pings the kiosk via WS coordinator.
app.post("/admin/kiosks/:id/os-update/push", async (event) => { app.post("/admin/kiosks/:id/os-update/push", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = (getRouterParam(event, "id") ?? "");
const { getCoordinator } = await import("../../shared/coordinator-registry.js"); const { getCoordinator } = await import("../../shared/coordinator-registry.js");
const dispatched = getCoordinator().sendToKiosk(id, { type: "os_check" }); const dispatched = getCoordinator().sendToKiosk(id, { type: "os_check" });
return { ok: true, dispatched }; return { ok: true, dispatched };
@ -93,10 +93,10 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" }); if (!release) throw createError({ statusCode: 404, statusMessage: "release not found" });
const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100); const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100);
const targetsRaw = body?.["target_kiosk_ids"]; const targetsRaw = body?.["target_kiosk_ids"];
const targets: number[] = Array.isArray(targetsRaw) const targets: string[] = Array.isArray(targetsRaw)
? targetsRaw.map((s) => Number(s)).filter((n) => Number.isFinite(n)) ? targetsRaw.map((s) => String(s)).filter((s) => s !== "")
: typeof targetsRaw === "string" && targetsRaw : typeof targetsRaw === "string" && targetsRaw
? targetsRaw.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n)) ? targetsRaw.split(",").map((s) => s.trim()).filter((s) => s !== "")
: []; : [];
const user = event.context.user!; const user = event.context.user!;
const rollout = await deps.repo.createOsUpdateRollout({ const rollout = await deps.repo.createOsUpdateRollout({

View file

@ -557,7 +557,7 @@ function registerKioskRoutes(
// Sync displays reported by the kiosk // Sync displays reported by the kiosk
if (Array.isArray(body?.displays)) { if (Array.isArray(body?.displays)) {
const existing = await repo.listDisplaysForKiosk(kiosk.id); const existing = await repo.listDisplaysForKiosk(kiosk.id);
const seenDisplayIds = new Set<number>(); const seenDisplayIds = new Set<string>();
for (const [position, reported] of body.displays.entries()) { for (const [position, reported] of body.displays.entries()) {
const reportedIndex = Number.isInteger(reported.index) && reported.index! >= 0 const reportedIndex = Number.isInteger(reported.index) && reported.index! >= 0
? reported.index! ? reported.index!
@ -659,7 +659,7 @@ function registerKioskRoutes(
const body = await readBody<{ const body = await readBody<{
topic: string; topic: string;
source_type?: string; source_type?: string;
camera_id?: number; camera_id?: string;
property_op?: string; property_op?: string;
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
}>(event); }>(event);
@ -699,9 +699,9 @@ function registerKioskRoutes(
// Side-effect: persist active layout per display so the admin UI can // Side-effect: persist active layout per display so the admin UI can
// surface "currently showing X" without having to query event_log. // surface "currently showing X" without having to query event_log.
if (body.topic === "layout.changed") { if (body.topic === "layout.changed") {
const displayId = Number(body.payload?.["display_id"]); const displayId = String(body.payload?.["display_id"] ?? "");
const layoutId = Number(body.payload?.["layout_id"]); const layoutId = String(body.payload?.["layout_id"] ?? "");
if (Number.isInteger(displayId) && Number.isInteger(layoutId)) { if (displayId && layoutId) {
try { try {
await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any); await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
} catch { } catch {
@ -1080,7 +1080,7 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token); const kiosk = await auth.verifyKioskKey(token);
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
const cameraId = Number(getRouterParam(event, "id")); const cameraId = (getRouterParam(event, "id") ?? "");
const camera = await repo.getCameraById(cameraId); const camera = await repo.getCameraById(cameraId);
if (!camera || camera.type !== "cloud" || !camera.cloud_account_id || !camera.cloud_vendor_camera_id) { if (!camera || camera.type !== "cloud" || !camera.cloud_account_id || !camera.cloud_vendor_camera_id) {
throw createError({ statusCode: 404, statusMessage: "Cloud camera not found" }); throw createError({ statusCode: 404, statusMessage: "Cloud camera not found" });
@ -1117,11 +1117,11 @@ function registerKioskRoutes(
* targets the same half of the fleet across re-checks. Switch from 50%100% * targets the same half of the fleet across re-checks. Switch from 50%100%
* gracefully adds the previously-excluded half rather than reshuffling. * gracefully adds the previously-excluded half rather than reshuffling.
*/ */
function isKioskInRolloutBucket(kioskId: number, rolloutId: string, percentage: number): boolean { function isKioskInRolloutBucket(kioskId: string, rolloutId: string, percentage: number): boolean {
if (percentage >= 100) return true; if (percentage >= 100) return true;
if (percentage <= 0) return false; if (percentage <= 0) return false;
const h = createHash("sha256") const h = createHash("sha256")
.update(`${rolloutId}:${String(kioskId)}`) .update(`${rolloutId}:${kioskId}`)
.digest(); .digest();
const bucket = h.readUInt32BE(0) % 100; const bucket = h.readUInt32BE(0) % 100;
return bucket < percentage; return bucket < percentage;

View file

@ -86,14 +86,14 @@ export const EventSchemas = createEventSchemas({
// ---- Connected kiosks ------------------------------------------------------- // ---- Connected kiosks -------------------------------------------------------
interface ConnectedKiosk { interface ConnectedKiosk {
id: number; id: string;
name: string; name: string;
ws: WebSocket; ws: WebSocket;
} }
const connectedKiosks = new Map<number, ConnectedKiosk>(); const connectedKiosks = new Map<string, ConnectedKiosk>();
const pendingRequests = new Map<string, { const pendingRequests = new Map<string, {
kioskId: number; kioskId: string;
resolve: (value: unknown) => void; resolve: (value: unknown) => void;
reject: (err: Error) => void; reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
@ -101,9 +101,9 @@ const pendingRequests = new Map<string, {
// Admin debug subscribers: admin WS connections subscribed to a kiosk's // Admin debug subscribers: admin WS connections subscribed to a kiosk's
// journal/terminal output. Keyed by kiosk id → set of admin WebSockets. // journal/terminal output. Keyed by kiosk id → set of admin WebSockets.
const debugSubscribers = new Map<number, Set<WebSocket>>(); const debugSubscribers = new Map<string, Set<WebSocket>>();
function addDebugSubscriber(kioskId: number, adminWs: WebSocket): void { function addDebugSubscriber(kioskId: string, adminWs: WebSocket): void {
let subs = debugSubscribers.get(kioskId); let subs = debugSubscribers.get(kioskId);
if (!subs) { subs = new Set(); debugSubscribers.set(kioskId, subs); } if (!subs) { subs = new Set(); debugSubscribers.set(kioskId, subs); }
subs.add(adminWs); subs.add(adminWs);
@ -117,7 +117,7 @@ function addDebugSubscriber(kioskId: number, adminWs: WebSocket): void {
}); });
} }
function relayToDebugSubscribers(kioskId: number, message: string): void { function relayToDebugSubscribers(kioskId: string, message: string): void {
const subs = debugSubscribers.get(kioskId); const subs = debugSubscribers.get(kioskId);
if (!subs) return; if (!subs) return;
for (const ws of subs) { for (const ws of subs) {
@ -136,9 +136,9 @@ function parseCookieValue(header: string, name: string): string | null {
// Per-kiosk message queue: if kiosk is offline, buffer messages here. // Per-kiosk message queue: if kiosk is offline, buffer messages here.
// Drain on reconnect. FIFO, cap at 100 messages per kiosk. // Drain on reconnect. FIFO, cap at 100 messages per kiosk.
const MESSAGE_QUEUE_CAP = 100; const MESSAGE_QUEUE_CAP = 100;
const offlineQueues = new Map<number, string[]>(); const offlineQueues = new Map<string, string[]>();
function sendToKiosk(kioskId: number, message: object): boolean { function sendToKiosk(kioskId: string, message: object): boolean {
const k = connectedKiosks.get(kioskId); const k = connectedKiosks.get(kioskId);
const payload = JSON.stringify(message); const payload = JSON.stringify(message);
if (!k || k.ws.readyState !== WebSocket.OPEN) { if (!k || k.ws.readyState !== WebSocket.OPEN) {
@ -153,7 +153,7 @@ function sendToKiosk(kioskId: number, message: object): boolean {
return true; return true;
} }
function drainOfflineQueue(kioskId: number): void { function drainOfflineQueue(kioskId: string): void {
const q = offlineQueues.get(kioskId); const q = offlineQueues.get(kioskId);
if (!q || q.length === 0) return; if (!q || q.length === 0) return;
const k = connectedKiosks.get(kioskId); const k = connectedKiosks.get(kioskId);
@ -164,7 +164,7 @@ function drainOfflineQueue(kioskId: number): void {
offlineQueues.delete(kioskId); offlineQueues.delete(kioskId);
} }
function requestKiosk<T = unknown>(kioskId: number, message: object, timeoutMs = 10000): Promise<T> { function requestKiosk<T = unknown>(kioskId: string, message: object, timeoutMs = 10000): Promise<T> {
const requestId = randomUUID(); const requestId = randomUUID();
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@ -271,8 +271,8 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
// Subscribes to a kiosk's journal + terminal output stream. // Subscribes to a kiosk's journal + terminal output stream.
if (url.pathname.startsWith("/ws/admin/debug/")) { if (url.pathname.startsWith("/ws/admin/debug/")) {
const kioskIdStr = url.pathname.split("/").pop() ?? ""; const kioskIdStr = url.pathname.split("/").pop() ?? "";
const kioskId = Number(kioskIdStr); const kioskId = String(kioskIdStr);
if (!Number.isInteger(kioskId) || kioskId <= 0) { if (!Number.isInteger(kioskId) || kioskId === "") {
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n"); socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
socket.destroy(); socket.destroy();
return; return;
@ -454,7 +454,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
requestKiosk, requestKiosk,
broadcastAll, broadcastAll,
notifyBundleChanged: () => broadcastAll({ type: "reload-bundle" }), notifyBundleChanged: () => broadcastAll({ type: "reload-bundle" }),
notifyKioskBundleChanged: (kioskId: number) => notifyKioskBundleChanged: (kioskId: string) =>
sendToKiosk(kioskId, { type: "reload-bundle" }), sendToKiosk(kioskId, { type: "reload-bundle" }),
}); });

View file

@ -12,7 +12,7 @@ import type { AuditActorType, AuditResult } from "./types.js";
interface AuditCtx { interface AuditCtx {
context?: { context?: {
user?: { id?: number; username?: string }; user?: { id?: string; username?: string };
apiKeyPrefix?: string; apiKeyPrefix?: string;
session?: unknown; session?: unknown;
}; };
@ -26,7 +26,7 @@ export interface AuditInput {
result?: AuditResult; result?: AuditResult;
/** Override actor (e.g. when system performs action on behalf of nobody). */ /** Override actor (e.g. when system performs action on behalf of nobody). */
actor_type?: AuditActorType; actor_type?: AuditActorType;
actor_id?: number | null; actor_id?: string | null;
actor_label?: string | null; actor_label?: string | null;
} }
@ -39,7 +39,7 @@ export async function audit(
try { try {
const ctx = event?.context; const ctx = event?.context;
let actor_type: AuditActorType = input.actor_type ?? "system"; let actor_type: AuditActorType = input.actor_type ?? "system";
let actor_id: number | null = input.actor_id ?? null; let actor_id: string | null = input.actor_id ?? null;
let actor_label: string | null = input.actor_label ?? null; let actor_label: string | null = input.actor_label ?? null;
if (!input.actor_type && ctx) { if (!input.actor_type && ctx) {

View file

@ -52,7 +52,7 @@ export interface AuthApi {
expiresAt: string | null; expiresAt: string | null;
}): Promise<{ apiKey: ApiKey; plaintext: string }>; }): Promise<{ apiKey: ApiKey; plaintext: string }>;
verifyApiKey(plaintext: string, ip: string | null): Promise<ApiKey | null>; verifyApiKey(plaintext: string, ip: string | null): Promise<ApiKey | null>;
verifyKioskKey(plaintext: string): Promise<{ id: number } | null>; verifyKioskKey(plaintext: string): Promise<{ id: string } | null>;
} }
// ---- Constants -------------------------------------------------------------- // ---- Constants --------------------------------------------------------------
@ -274,7 +274,7 @@ export function createAuth(
return null; return null;
} }
async function verifyKioskKey(plaintext: string): Promise<{ id: number } | null> { async function verifyKioskKey(plaintext: string): Promise<{ id: string } | null> {
if (plaintext.length < 8) return null; if (plaintext.length < 8) return null;
const prefix = plaintext.slice(0, 8); const prefix = plaintext.slice(0, 8);
const candidates = await repo.listKiosksByKeyPrefix(prefix); const candidates = await repo.listKiosksByKeyPrefix(prefix);

View file

@ -40,7 +40,7 @@ function buildStreamRtspUri(stream: CameraStream, cam: Camera): string {
} }
export interface BundleCamera { export interface BundleCamera {
id: number; id: string;
name: string; name: string;
type: string; type: string;
rtsp_url: string | null; rtsp_url: string | null;
@ -52,7 +52,7 @@ export interface BundleCamera {
event_sink: string; event_sink: string;
stream_policy: string; stream_policy: string;
streams: Array<{ streams: Array<{
id: number; id: string;
role: string; role: string;
name: string; name: string;
/** Final playable RTSP URL with properly encoded credentials. */ /** Final playable RTSP URL with properly encoded credentials. */
@ -70,7 +70,7 @@ export interface BundleCell {
row_span: number; row_span: number;
col_span: number; col_span: number;
content_type: string; content_type: string;
camera_id: number | null; camera_id: string | null;
stream_selector: string | null; stream_selector: string | null;
web_url: string | null; web_url: string | null;
html_content: string | null; html_content: string | null;
@ -94,7 +94,7 @@ export interface BundleCell {
} }
export interface BundleLayout { export interface BundleLayout {
id: number; id: string;
name: string; name: string;
/** Computed from cells: max(col + col_span). 1 if no cells. */ /** Computed from cells: max(col + col_span). 1 if no cells. */
grid_cols: number; grid_cols: number;
@ -102,7 +102,7 @@ export interface BundleLayout {
grid_rows: number; grid_rows: number;
priority: string; priority: string;
cooling_timeout_seconds: number | null; cooling_timeout_seconds: number | null;
preload_camera_ids: number[]; preload_camera_ids: string[];
resets_idle_timer: boolean; resets_idle_timer: boolean;
/** True if the kiosk's display has this layout as its default_layout_id. */ /** True if the kiosk's display has this layout as its default_layout_id. */
is_default: boolean; is_default: boolean;
@ -110,13 +110,13 @@ export interface BundleLayout {
} }
export interface BundleDisplay { export interface BundleDisplay {
id: number; id: string;
name: string; name: string;
width_px: number; width_px: number;
height_px: number; height_px: number;
idle_timeout_seconds: number; idle_timeout_seconds: number;
sleep_timeout_seconds: number; sleep_timeout_seconds: number;
default_layout_id: number | null; default_layout_id: string | null;
} }
export interface BundleDisplayWithLayouts extends BundleDisplay { export interface BundleDisplayWithLayouts extends BundleDisplay {
@ -124,7 +124,7 @@ export interface BundleDisplayWithLayouts extends BundleDisplay {
} }
export interface BundleGpioBinding { export interface BundleGpioBinding {
id: number; id: string;
chip: string; chip: string;
pin: number; pin: number;
direction: "in" | "out"; direction: "in" | "out";
@ -134,7 +134,7 @@ export interface BundleGpioBinding {
} }
export interface KioskBundle { export interface KioskBundle {
kiosk_id: number; kiosk_id: string;
kiosk_name: string; kiosk_name: string;
/** /**
* @deprecated Use `displays` (array). Kept for backward compat with older * @deprecated Use `displays` (array). Kept for backward compat with older
@ -156,7 +156,7 @@ export interface KioskBundle {
export async function generateBundle( export async function generateBundle(
repo: Repository, repo: Repository,
secrets: SecretsApi, secrets: SecretsApi,
kioskId: number, kioskId: string,
clusterKey: string | undefined, clusterKey: string | undefined,
obs?: Observable, obs?: Observable,
): Promise<KioskBundle | null> { ): Promise<KioskBundle | null> {
@ -194,13 +194,13 @@ export async function generateBundle(
} }
// Collect camera IDs across ALL displays' layouts (de-duped). // Collect camera IDs across ALL displays' layouts (de-duped).
const allLayoutIds = new Set<number>(); const allLayoutIds = new Set<string>();
for (const d of displays) { for (const d of displays) {
for (const l of await repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id); for (const l of await repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id);
} }
const cameras = await repo.camerasForLayoutIds([...allLayoutIds]); const cameras = await repo.camerasForLayoutIds([...allLayoutIds]);
async function buildLayouts(displayId: number, defaultLayoutId: number | null): Promise<BundleLayout[]> { async function buildLayouts(displayId: string, defaultLayoutId: string | null): Promise<BundleLayout[]> {
const layouts = await repo.layoutsForDisplayId(displayId); const layouts = await repo.layoutsForDisplayId(displayId);
const result: BundleLayout[] = []; const result: BundleLayout[] = [];
for (const l of layouts) { for (const l of layouts) {
@ -308,7 +308,7 @@ export async function generateBundle(
const effectiveStreams = streams.length > 0 ? streams : ( const effectiveStreams = streams.length > 0 ? streams : (
cam.type === "rtsp" && cam.rtsp_url cam.type === "rtsp" && cam.rtsp_url
? [{ ? [{
id: 0, id: "",
role: "main" as const, role: "main" as const,
name: "Main", name: "Main",
rtsp_uri: cam.rtsp_url, rtsp_uri: cam.rtsp_url,

View file

@ -3,11 +3,11 @@
* service-coordinator-ws sets the implementation in its init(). * service-coordinator-ws sets the implementation in its init().
*/ */
export interface CoordinatorApi { export interface CoordinatorApi {
sendToKiosk(kioskId: number, message: object): boolean; sendToKiosk(kioskId: string, message: object): boolean;
requestKiosk<T = unknown>(kioskId: number, message: object, timeoutMs?: number): Promise<T>; requestKiosk<T = unknown>(kioskId: string, message: object, timeoutMs?: number): Promise<T>;
broadcastAll(message: object): void; broadcastAll(message: object): void;
notifyBundleChanged(): void; notifyBundleChanged(): void;
notifyKioskBundleChanged(kioskId: number): void; notifyKioskBundleChanged(kioskId: string): void;
} }
const noop: CoordinatorApi = { const noop: CoordinatorApi = {

View file

@ -69,7 +69,7 @@ const nn = (v: unknown): number | null =>
export function rowToUser(r: Row): User { export function rowToUser(r: Row): User {
return { return {
id: n(r["id"]), id: s(r["id"]),
username: s(r["username"]), username: s(r["username"]),
password_hash: s(r["password_hash"]), password_hash: s(r["password_hash"]),
role: s(r["role"]) as UserRole, role: s(r["role"]) as UserRole,
@ -88,7 +88,7 @@ export function rowToUser(r: Row): User {
export function rowToSession(r: Row): Session { export function rowToSession(r: Row): Session {
return { return {
id: s(r["id"]), id: s(r["id"]),
user_id: n(r["user_id"]), user_id: s(r["user_id"]),
csrf_token: s(r["csrf_token"]), csrf_token: s(r["csrf_token"]),
totp_pending: b(r["totp_pending"]), totp_pending: b(r["totp_pending"]),
user_agent: sn(r["user_agent"]), user_agent: sn(r["user_agent"]),
@ -102,7 +102,7 @@ export function rowToSession(r: Row): Session {
export function rowToApiKey(r: Row): ApiKey { export function rowToApiKey(r: Row): ApiKey {
return { return {
id: n(r["id"]), id: s(r["id"]),
name: s(r["name"]), name: s(r["name"]),
key_hash: s(r["key_hash"]), key_hash: s(r["key_hash"]),
key_prefix: s(r["key_prefix"]), key_prefix: s(r["key_prefix"]),
@ -128,14 +128,14 @@ export function rowToSetupState(r: Row): SetupState {
export function rowToDisplay(r: Row): Display { export function rowToDisplay(r: Row): Display {
return { return {
id: n(r["id"]), id: s(r["id"]),
name: s(r["name"]), name: s(r["name"]),
index: n(r["index"]), index: n(r["index"]),
is_primary: b(r["is_primary"]), is_primary: b(r["is_primary"]),
kiosk_id: nn(r["kiosk_id"]), kiosk_id: sn(r["kiosk_id"]),
width_px: n(r["width_px"]), width_px: n(r["width_px"]),
height_px: n(r["height_px"]), height_px: n(r["height_px"]),
default_layout_id: nn(r["default_layout_id"]), default_layout_id: sn(r["default_layout_id"]),
idle_timeout_seconds: n(r["idle_timeout_seconds"]), idle_timeout_seconds: n(r["idle_timeout_seconds"]),
sleep_timeout_seconds: n(r["sleep_timeout_seconds"]), sleep_timeout_seconds: n(r["sleep_timeout_seconds"]),
cec_enabled: b(r["cec_enabled"]), cec_enabled: b(r["cec_enabled"]),
@ -147,13 +147,13 @@ export function rowToDisplay(r: Row): Display {
state_check_enabled: b(r["state_check_enabled"]), state_check_enabled: b(r["state_check_enabled"]),
state_check_interval_seconds: n(r["state_check_interval_seconds"]), state_check_interval_seconds: n(r["state_check_interval_seconds"]),
is_enabled: b(r["is_enabled"]), is_enabled: b(r["is_enabled"]),
active_layout_id: nn(r["active_layout_id"]), active_layout_id: sn(r["active_layout_id"]),
}; };
} }
export function rowToCamera(r: Row): Camera { export function rowToCamera(r: Row): Camera {
return { return {
id: n(r["id"]), id: s(r["id"]),
name: s(r["name"]), name: s(r["name"]),
type: s(r["type"]) as CameraType, type: s(r["type"]) as CameraType,
rtsp_url: sn(r["rtsp_url"]), rtsp_url: sn(r["rtsp_url"]),
@ -178,8 +178,8 @@ export function rowToCamera(r: Row): Camera {
export function rowToCameraStream(r: Row): CameraStream { export function rowToCameraStream(r: Row): CameraStream {
return { return {
id: n(r["id"]), id: s(r["id"]),
camera_id: n(r["camera_id"]), camera_id: s(r["camera_id"]),
role: s(r["role"]) as StreamRole, role: s(r["role"]) as StreamRole,
name: s(r["name"]), name: s(r["name"]),
profile_token: sn(r["profile_token"]), profile_token: sn(r["profile_token"]),
@ -198,7 +198,7 @@ export function rowToCameraStream(r: Row): CameraStream {
export function rowToLayoutTemplate(r: Row): LayoutTemplate { export function rowToLayoutTemplate(r: Row): LayoutTemplate {
return { return {
id: n(r["id"]), id: s(r["id"]),
name: s(r["name"]), name: s(r["name"]),
description: sn(r["description"]), description: sn(r["description"]),
regions: j<LayoutRegion[]>(r["regions"], []), regions: j<LayoutRegion[]>(r["regions"], []),
@ -210,17 +210,17 @@ export function rowToLayoutTemplate(r: Row): LayoutTemplate {
export function rowToLayout(r: Row): Layout { export function rowToLayout(r: Row): Layout {
return { return {
id: n(r["id"]), id: s(r["id"]),
name: s(r["name"]), name: s(r["name"]),
description: sn(r["description"]), description: sn(r["description"]),
template_id: nn(r["template_id"]), template_id: sn(r["template_id"]),
regions: j<LayoutRegion[]>(r["regions"], []), regions: j<LayoutRegion[]>(r["regions"], []),
grid_cols: n(r["grid_cols"]) || 1, grid_cols: n(r["grid_cols"]) || 1,
grid_rows: n(r["grid_rows"]) || 1, grid_rows: n(r["grid_rows"]) || 1,
display_id: nn(r["display_id"]), display_id: sn(r["display_id"]),
priority: s(r["priority"]) as LayoutPriority, priority: s(r["priority"]) as LayoutPriority,
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]), cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
preload_camera_ids: j<number[]>(r["preload_camera_ids"], []), preload_camera_ids: j<string[]>(r["preload_camera_ids"], []),
is_default: b(r["is_default"]), is_default: b(r["is_default"]),
resets_idle_timer: b(r["resets_idle_timer"]), resets_idle_timer: b(r["resets_idle_timer"]),
}; };
@ -228,32 +228,32 @@ export function rowToLayout(r: Row): Layout {
export function rowToLayoutCell(r: Row): LayoutCell { export function rowToLayoutCell(r: Row): LayoutCell {
return { return {
id: n(r["id"]), id: s(r["id"]),
layout_id: n(r["layout_id"]), layout_id: s(r["layout_id"]),
region_name: s(r["region_name"]), region_name: s(r["region_name"]),
row: n(r["row"]), row: n(r["row"]),
col: n(r["col"]), col: n(r["col"]),
row_span: n(r["row_span"]) || 1, row_span: n(r["row_span"]) || 1,
col_span: n(r["col_span"]) || 1, col_span: n(r["col_span"]) || 1,
content_type: s(r["content_type"]) as CellContentType, content_type: s(r["content_type"]) as CellContentType,
camera_id: nn(r["camera_id"]), camera_id: sn(r["camera_id"]),
stream_selector: s(r["stream_selector"]) as StreamSelector, stream_selector: s(r["stream_selector"]) as StreamSelector,
web_url: sn(r["web_url"]), web_url: sn(r["web_url"]),
html_content: sn(r["html_content"]), html_content: sn(r["html_content"]),
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]), cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
options: j<Record<string, unknown>>(r["options"], {}), options: j<Record<string, unknown>>(r["options"], {}),
entity_id: nn(r["entity_id"]), entity_id: sn(r["entity_id"]),
fit: (s(r["fit"]) || "cover") as "cover" | "contain" | "fill", fit: (s(r["fit"]) || "cover") as "cover" | "contain" | "fill",
}; };
} }
export function rowToEntity(r: Row): Entity { export function rowToEntity(r: Row): Entity {
return { return {
id: n(r["id"]), id: s(r["id"]),
name: s(r["name"]), name: s(r["name"]),
type: s(r["type"]) as EntityType, type: s(r["type"]) as EntityType,
description: sn(r["description"]), description: sn(r["description"]),
camera_id: nn(r["camera_id"]), camera_id: sn(r["camera_id"]),
html_content: sn(r["html_content"]), html_content: sn(r["html_content"]),
web_url: sn(r["web_url"]), web_url: sn(r["web_url"]),
dashboard_id: sn(r["dashboard_id"]), dashboard_id: sn(r["dashboard_id"]),
@ -263,7 +263,7 @@ export function rowToEntity(r: Row): Entity {
export function rowToKiosk(r: Row): Kiosk { export function rowToKiosk(r: Row): Kiosk {
return { return {
id: n(r["id"]), id: s(r["id"]),
name: s(r["name"]), name: s(r["name"]),
description: sn(r["description"]), description: sn(r["description"]),
key_hash: s(r["key_hash"]), key_hash: s(r["key_hash"]),
@ -276,7 +276,7 @@ export function rowToKiosk(r: Row): Kiosk {
paired_at: sn(r["paired_at"]), paired_at: sn(r["paired_at"]),
last_seen_at: sn(r["last_seen_at"]), last_seen_at: sn(r["last_seen_at"]),
last_bundle_version: sn(r["last_bundle_version"]), last_bundle_version: sn(r["last_bundle_version"]),
display_id: nn(r["display_id"]), display_id: sn(r["display_id"]),
cpu_temp_c: nn(r["cpu_temp_c"]), cpu_temp_c: nn(r["cpu_temp_c"]),
cpu_load_percent: nn(r["cpu_load_percent"]), cpu_load_percent: nn(r["cpu_load_percent"]),
fan_rpm: nn(r["fan_rpm"]), fan_rpm: nn(r["fan_rpm"]),
@ -314,10 +314,10 @@ export function rowToKiosk(r: Row): Kiosk {
export function rowToAuditEntry(r: Row): AuditEntry { export function rowToAuditEntry(r: Row): AuditEntry {
return { return {
id: n(r["id"]), id: s(r["id"]),
ts: s(r["ts"]), ts: s(r["ts"]),
actor_type: s(r["actor_type"]) as AuditActorType, actor_type: s(r["actor_type"]) as AuditActorType,
actor_id: nn(r["actor_id"]), actor_id: sn(r["actor_id"]),
actor_label: sn(r["actor_label"]), actor_label: sn(r["actor_label"]),
action: s(r["action"]), action: s(r["action"]),
resource_type: sn(r["resource_type"]), resource_type: sn(r["resource_type"]),
@ -340,7 +340,7 @@ export function rowToFirmwareRelease(r: Row): FirmwareRelease {
signature: s(r["signature"]), signature: s(r["signature"]),
release_notes: sn(r["release_notes"]), release_notes: sn(r["release_notes"]),
uploaded_at: s(r["uploaded_at"]), uploaded_at: s(r["uploaded_at"]),
uploaded_by: nn(r["uploaded_by"]), uploaded_by: sn(r["uploaded_by"]),
yanked_at: sn(r["yanked_at"]), yanked_at: sn(r["yanked_at"]),
}; };
} }
@ -349,13 +349,13 @@ export function rowToFirmwareRollout(r: Row): FirmwareRollout {
return { return {
id: s(r["id"]), id: s(r["id"]),
release_id: s(r["release_id"]), release_id: s(r["release_id"]),
target_kiosk_ids: j<number[]>(r["target_kiosk_ids"], []), target_kiosk_ids: j<string[]>(r["target_kiosk_ids"], []),
state: s(r["state"]) as FirmwareRolloutState, state: s(r["state"]) as FirmwareRolloutState,
percentage: n(r["percentage"]), percentage: n(r["percentage"]),
started_at: sn(r["started_at"]), started_at: sn(r["started_at"]),
finished_at: sn(r["finished_at"]), finished_at: sn(r["finished_at"]),
created_at: s(r["created_at"]), created_at: s(r["created_at"]),
created_by: nn(r["created_by"]), created_by: sn(r["created_by"]),
}; };
} }
@ -371,7 +371,7 @@ export function rowToOsUpdateRelease(r: Row): OsUpdateRelease {
bundle_format: "raucb", bundle_format: "raucb",
release_notes: sn(r["release_notes"]), release_notes: sn(r["release_notes"]),
uploaded_at: s(r["uploaded_at"]), uploaded_at: s(r["uploaded_at"]),
uploaded_by: nn(r["uploaded_by"]), uploaded_by: sn(r["uploaded_by"]),
yanked_at: sn(r["yanked_at"]), yanked_at: sn(r["yanked_at"]),
}; };
} }
@ -380,19 +380,19 @@ export function rowToOsUpdateRollout(r: Row): OsUpdateRollout {
return { return {
id: s(r["id"]), id: s(r["id"]),
release_id: s(r["release_id"]), release_id: s(r["release_id"]),
target_kiosk_ids: j<number[]>(r["target_kiosk_ids"], []), target_kiosk_ids: j<string[]>(r["target_kiosk_ids"], []),
state: s(r["state"]) as OsUpdateRolloutState, state: s(r["state"]) as OsUpdateRolloutState,
percentage: n(r["percentage"]), percentage: n(r["percentage"]),
started_at: sn(r["started_at"]), started_at: sn(r["started_at"]),
finished_at: sn(r["finished_at"]), finished_at: sn(r["finished_at"]),
created_at: s(r["created_at"]), created_at: s(r["created_at"]),
created_by: nn(r["created_by"]), created_by: sn(r["created_by"]),
}; };
} }
export function rowToLabel(r: Row): Label { export function rowToLabel(r: Row): Label {
return { return {
id: n(r["id"]), id: s(r["id"]),
name: s(r["name"]), name: s(r["name"]),
description: sn(r["description"]), description: sn(r["description"]),
color: sn(r["color"]), color: sn(r["color"]),
@ -402,8 +402,8 @@ export function rowToLabel(r: Row): Label {
export function rowToKioskLabel(r: Row): KioskLabel { export function rowToKioskLabel(r: Row): KioskLabel {
return { return {
kiosk_id: n(r["kiosk_id"]), kiosk_id: s(r["kiosk_id"]),
label_id: n(r["label_id"]), label_id: s(r["label_id"]),
role: s(r["role"]) as LabelRole, role: s(r["role"]) as LabelRole,
}; };
} }
@ -417,7 +417,7 @@ export function rowToPairingCode(r: Row): PairingCode {
issued_at: s(r["issued_at"]), issued_at: s(r["issued_at"]),
expires_at: s(r["expires_at"]), expires_at: s(r["expires_at"]),
consumed_at: sn(r["consumed_at"]), consumed_at: sn(r["consumed_at"]),
consumed_by_kiosk_id: nn(r["consumed_by_kiosk_id"]), consumed_by_kiosk_id: sn(r["consumed_by_kiosk_id"]),
extras: j<Record<string, unknown>>(r["extras"], {}), extras: j<Record<string, unknown>>(r["extras"], {}),
}; };
} }
@ -426,8 +426,8 @@ export function rowToKioskGpioBinding(r: Row): KioskGpioBinding {
const pullRaw = sn(r["pull"]); const pullRaw = sn(r["pull"]);
const edgeRaw = sn(r["edge"]); const edgeRaw = sn(r["edge"]);
return { return {
id: n(r["id"]), id: s(r["id"]),
kiosk_id: n(r["kiosk_id"]), kiosk_id: s(r["kiosk_id"]),
chip: s(r["chip"]) || "gpiochip0", chip: s(r["chip"]) || "gpiochip0",
pin: n(r["pin"]), pin: n(r["pin"]),
direction: s(r["direction"]) as GpioDirection, direction: s(r["direction"]) as GpioDirection,
@ -440,9 +440,9 @@ export function rowToKioskGpioBinding(r: Row): KioskGpioBinding {
export function rowToEventLog(r: Row): EventLog { export function rowToEventLog(r: Row): EventLog {
return { return {
id: n(r["id"]), id: s(r["id"]),
source_kiosk_id: nn(r["source_kiosk_id"]), source_kiosk_id: sn(r["source_kiosk_id"]),
source_camera_id: nn(r["source_camera_id"]), source_camera_id: sn(r["source_camera_id"]),
source_type: s(r["source_type"]) as EventSourceType, source_type: s(r["source_type"]) as EventSourceType,
topic: s(r["topic"]), topic: s(r["topic"]),
property_op: sn(r["property_op"]), property_op: sn(r["property_op"]),
@ -454,8 +454,8 @@ export function rowToEventLog(r: Row): EventLog {
export function rowToKioskLog(r: Row): KioskLog { export function rowToKioskLog(r: Row): KioskLog {
return { return {
id: n(r["id"]), id: s(r["id"]),
kiosk_id: n(r["kiosk_id"]), kiosk_id: s(r["kiosk_id"]),
level: s(r["level"]) as KioskLogLevel, level: s(r["level"]) as KioskLogLevel,
message: s(r["message"]), message: s(r["message"]),
context: j<Record<string, unknown>>(r["context"], {}), context: j<Record<string, unknown>>(r["context"], {}),
@ -480,11 +480,11 @@ export function rowToCloudAccount(r: Row): CloudAccount {
export function rowToCameraEventSubscription(r: Row): CameraEventSubscription { export function rowToCameraEventSubscription(r: Row): CameraEventSubscription {
return { return {
id: n(r["id"]), id: s(r["id"]),
camera_id: n(r["camera_id"]), camera_id: s(r["camera_id"]),
topic: s(r["topic"]), topic: s(r["topic"]),
status: s(r["status"]) as EventSubscriptionStatus, status: s(r["status"]) as EventSubscriptionStatus,
subscribed_by_kiosk_id: nn(r["subscribed_by_kiosk_id"]), subscribed_by_kiosk_id: sn(r["subscribed_by_kiosk_id"]),
event_source: sn(r["event_source"]), event_source: sn(r["event_source"]),
event_sink: sn(r["event_sink"]), event_sink: sn(r["event_sink"]),
last_event_at: sn(r["last_event_at"]), last_event_at: sn(r["last_event_at"]),

View file

@ -36,7 +36,7 @@ export const PUBLIC_MIGRATIONS: readonly string[] = [
)`, )`,
`CREATE TABLE IF NOT EXISTS global_admins ( `CREATE TABLE IF NOT EXISTS global_admins (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true, is_active BOOLEAN NOT NULL DEFAULT true,
@ -60,7 +60,7 @@ export const PUBLIC_MIGRATIONS: readonly string[] = [
export const TENANT_MIGRATIONS: readonly string[] = [ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- users --------------------------------------------------------------- // ---- users ---------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'operator' CHECK(role IN ('admin', 'operator')), role TEXT NOT NULL DEFAULT 'operator' CHECK(role IN ('admin', 'operator')),
@ -78,7 +78,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- sessions ------------------------------------------------------------ // ---- sessions ------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS sessions ( `CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL, csrf_token TEXT NOT NULL,
totp_pending BOOLEAN NOT NULL DEFAULT false, totp_pending BOOLEAN NOT NULL DEFAULT false,
user_agent TEXT, user_agent TEXT,
@ -93,7 +93,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- api_keys ------------------------------------------------------------ // ---- api_keys ------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS api_keys ( `CREATE TABLE IF NOT EXISTS api_keys (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
key_hash TEXT NOT NULL, key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL, key_prefix TEXT NOT NULL,
@ -119,14 +119,14 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- displays (final schema — no UNIQUE on index, has kiosk_id) ---------- // ---- displays (final schema — no UNIQUE on index, has kiosk_id) ----------
`CREATE TABLE IF NOT EXISTS displays ( `CREATE TABLE IF NOT EXISTS displays (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
"index" INTEGER NOT NULL, "index" INTEGER NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT false, is_primary BOOLEAN NOT NULL DEFAULT false,
kiosk_id INTEGER, kiosk_id TEXT,
width_px INTEGER NOT NULL DEFAULT 1920, width_px INTEGER NOT NULL DEFAULT 1920,
height_px INTEGER NOT NULL DEFAULT 1080, height_px INTEGER NOT NULL DEFAULT 1080,
default_layout_id INTEGER, default_layout_id TEXT,
idle_timeout_seconds INTEGER NOT NULL DEFAULT 600, idle_timeout_seconds INTEGER NOT NULL DEFAULT 600,
sleep_timeout_seconds INTEGER NOT NULL DEFAULT 1800, sleep_timeout_seconds INTEGER NOT NULL DEFAULT 1800,
cec_enabled BOOLEAN NOT NULL DEFAULT true, cec_enabled BOOLEAN NOT NULL DEFAULT true,
@ -140,14 +140,14 @@ export const TENANT_MIGRATIONS: readonly string[] = [
state_check_enabled BOOLEAN NOT NULL DEFAULT false, state_check_enabled BOOLEAN NOT NULL DEFAULT false,
state_check_interval_seconds INTEGER NOT NULL DEFAULT 60, state_check_interval_seconds INTEGER NOT NULL DEFAULT 60,
is_enabled BOOLEAN NOT NULL DEFAULT true, is_enabled BOOLEAN NOT NULL DEFAULT true,
active_layout_id INTEGER active_layout_id TEXT
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_displays_kiosk ON displays(kiosk_id)`, `CREATE INDEX IF NOT EXISTS idx_displays_kiosk ON displays(kiosk_id)`,
`CREATE INDEX IF NOT EXISTS idx_displays_kiosk_index ON displays(kiosk_id, "index")`, `CREATE INDEX IF NOT EXISTS idx_displays_kiosk_index ON displays(kiosk_id, "index")`,
// ---- cameras ------------------------------------------------------------- // ---- cameras -------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS cameras ( `CREATE TABLE IF NOT EXISTS cameras (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif', 'cloud')), type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif', 'cloud')),
rtsp_url TEXT, rtsp_url TEXT,
@ -172,8 +172,8 @@ export const TENANT_MIGRATIONS: readonly string[] = [
`CREATE INDEX IF NOT EXISTS idx_cameras_cloud_account ON cameras(cloud_account_id)`, `CREATE INDEX IF NOT EXISTS idx_cameras_cloud_account ON cameras(cloud_account_id)`,
`CREATE TABLE IF NOT EXISTS camera_streams ( `CREATE TABLE IF NOT EXISTS camera_streams (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE, camera_id TEXT NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('main', 'sub', 'other')), role TEXT NOT NULL CHECK(role IN ('main', 'sub', 'other')),
name TEXT NOT NULL, name TEXT NOT NULL,
profile_token TEXT, profile_token TEXT,
@ -192,7 +192,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- kiosks (final schema — all telemetry + update columns) -------------- // ---- kiosks (final schema — all telemetry + update columns) --------------
`CREATE TABLE IF NOT EXISTS kiosks ( `CREATE TABLE IF NOT EXISTS kiosks (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
description TEXT, description TEXT,
key_hash TEXT NOT NULL, key_hash TEXT NOT NULL,
@ -205,7 +205,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
paired_at TIMESTAMPTZ, paired_at TIMESTAMPTZ,
last_seen_at TIMESTAMPTZ, last_seen_at TIMESTAMPTZ,
last_bundle_version TEXT, last_bundle_version TEXT,
display_id INTEGER REFERENCES displays(id) ON DELETE SET NULL, display_id TEXT REFERENCES displays(id) ON DELETE SET NULL,
encrypt_key_encrypted TEXT, encrypt_key_encrypted TEXT,
cpu_temp_c REAL, cpu_temp_c REAL,
cpu_load_percent REAL, cpu_load_percent REAL,
@ -243,7 +243,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- layouts (final schema — no template_id, no display_id) -------------- // ---- layouts (final schema — no template_id, no display_id) --------------
`CREATE TABLE IF NOT EXISTS layouts ( `CREATE TABLE IF NOT EXISTS layouts (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
description TEXT, description TEXT,
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('hot', 'normal', 'cold')), priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('hot', 'normal', 'cold')),
@ -254,28 +254,28 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- display_layouts (join table) ---------------------------------------- // ---- display_layouts (join table) ----------------------------------------
`CREATE TABLE IF NOT EXISTS display_layouts ( `CREATE TABLE IF NOT EXISTS display_layouts (
display_id INTEGER NOT NULL REFERENCES displays(id) ON DELETE CASCADE, display_id TEXT NOT NULL REFERENCES displays(id) ON DELETE CASCADE,
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
PRIMARY KEY (display_id, layout_id) PRIMARY KEY (display_id, layout_id)
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_display_layouts_layout ON display_layouts(layout_id)`, `CREATE INDEX IF NOT EXISTS idx_display_layouts_layout ON display_layouts(layout_id)`,
// ---- layout_cells (final schema — no region_name) ------------------------ // ---- layout_cells (final schema — no region_name) ------------------------
`CREATE TABLE IF NOT EXISTS layout_cells ( `CREATE TABLE IF NOT EXISTS layout_cells (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
"row" INTEGER NOT NULL DEFAULT 0, "row" INTEGER NOT NULL DEFAULT 0,
col INTEGER NOT NULL DEFAULT 0, col INTEGER NOT NULL DEFAULT 0,
row_span INTEGER NOT NULL DEFAULT 1, row_span INTEGER NOT NULL DEFAULT 1,
col_span INTEGER NOT NULL DEFAULT 1, col_span INTEGER NOT NULL DEFAULT 1,
content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')), content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')),
camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL, camera_id TEXT REFERENCES cameras(id) ON DELETE SET NULL,
stream_selector TEXT, stream_selector TEXT,
web_url TEXT, web_url TEXT,
html_content TEXT, html_content TEXT,
cooling_timeout_seconds INTEGER, cooling_timeout_seconds INTEGER,
options JSONB NOT NULL DEFAULT '{}', options JSONB NOT NULL DEFAULT '{}',
entity_id INTEGER, entity_id TEXT,
fit TEXT NOT NULL DEFAULT 'cover' fit TEXT NOT NULL DEFAULT 'cover'
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_layout_cells_layout ON layout_cells(layout_id)`, `CREATE INDEX IF NOT EXISTS idx_layout_cells_layout ON layout_cells(layout_id)`,
@ -283,26 +283,26 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- labels -------------------------------------------------------------- // ---- labels --------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS labels ( `CREATE TABLE IF NOT EXISTS labels (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
description TEXT, description TEXT,
color TEXT, color TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`, )`,
`CREATE TABLE IF NOT EXISTS kiosk_labels ( `CREATE TABLE IF NOT EXISTS kiosk_labels (
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('consume', 'operate')), role TEXT NOT NULL CHECK(role IN ('consume', 'operate')),
PRIMARY KEY (kiosk_id, label_id, role) PRIMARY KEY (kiosk_id, label_id, role)
)`, )`,
`CREATE TABLE IF NOT EXISTS camera_labels ( `CREATE TABLE IF NOT EXISTS camera_labels (
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE, camera_id TEXT NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (camera_id, label_id) PRIMARY KEY (camera_id, label_id)
)`, )`,
`CREATE TABLE IF NOT EXISTS layout_labels ( `CREATE TABLE IF NOT EXISTS layout_labels (
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (layout_id, label_id) PRIMARY KEY (layout_id, label_id)
)`, )`,
@ -315,15 +315,15 @@ export const TENANT_MIGRATIONS: readonly string[] = [
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(), issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ, consumed_at TIMESTAMPTZ,
consumed_by_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL, consumed_by_kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL,
extras JSONB NOT NULL DEFAULT '{}' extras JSONB NOT NULL DEFAULT '{}'
)`, )`,
// ---- event_log ----------------------------------------------------------- // ---- event_log -----------------------------------------------------------
`CREATE TABLE IF NOT EXISTS event_log ( `CREATE TABLE IF NOT EXISTS event_log (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
source_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL, source_kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL,
source_camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL, source_camera_id TEXT REFERENCES cameras(id) ON DELETE SET NULL,
source_type TEXT NOT NULL CHECK(source_type IN ('onvif', 'gpio', 'synthetic', 'system')), source_type TEXT NOT NULL CHECK(source_type IN ('onvif', 'gpio', 'synthetic', 'system')),
topic TEXT NOT NULL, topic TEXT NOT NULL,
property_op TEXT, property_op TEXT,
@ -336,11 +336,11 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- entities ------------------------------------------------------------ // ---- entities ------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS entities ( `CREATE TABLE IF NOT EXISTS entities (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK(type IN ('camera', 'html', 'web', 'dashboard')), type TEXT NOT NULL CHECK(type IN ('camera', 'html', 'web', 'dashboard')),
description TEXT, description TEXT,
camera_id INTEGER REFERENCES cameras(id) ON DELETE CASCADE, camera_id TEXT REFERENCES cameras(id) ON DELETE CASCADE,
html_content TEXT, html_content TEXT,
web_url TEXT, web_url TEXT,
dashboard_id TEXT, dashboard_id TEXT,
@ -360,7 +360,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
signature TEXT NOT NULL, signature TEXT NOT NULL,
release_notes TEXT, release_notes TEXT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL, uploaded_by TEXT REFERENCES users(id) ON DELETE SET NULL,
yanked_at TIMESTAMPTZ, yanked_at TIMESTAMPTZ,
UNIQUE(version, arch) UNIQUE(version, arch)
)`, )`,
@ -375,7 +375,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
started_at TIMESTAMPTZ, started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ, finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL created_by TEXT REFERENCES users(id) ON DELETE SET NULL
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_firmware_rollouts_state ON firmware_rollouts(state)`, `CREATE INDEX IF NOT EXISTS idx_firmware_rollouts_state ON firmware_rollouts(state)`,
@ -391,7 +391,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
bundle_format TEXT NOT NULL DEFAULT 'raucb' CHECK(bundle_format = 'raucb'), bundle_format TEXT NOT NULL DEFAULT 'raucb' CHECK(bundle_format = 'raucb'),
release_notes TEXT, release_notes TEXT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL, uploaded_by TEXT REFERENCES users(id) ON DELETE SET NULL,
yanked_at TIMESTAMPTZ, yanked_at TIMESTAMPTZ,
UNIQUE(version, compatibility) UNIQUE(version, compatibility)
)`, )`,
@ -406,16 +406,16 @@ export const TENANT_MIGRATIONS: readonly string[] = [
started_at TIMESTAMPTZ, started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ, finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL created_by TEXT REFERENCES users(id) ON DELETE SET NULL
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_os_update_rollouts_state ON os_update_rollouts(state)`, `CREATE INDEX IF NOT EXISTS idx_os_update_rollouts_state ON os_update_rollouts(state)`,
// ---- audit_log ----------------------------------------------------------- // ---- audit_log -----------------------------------------------------------
`CREATE TABLE IF NOT EXISTS audit_log ( `CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(), ts TIMESTAMPTZ NOT NULL DEFAULT now(),
actor_type TEXT NOT NULL CHECK(actor_type IN ('user', 'api_key', 'system', 'kiosk')), actor_type TEXT NOT NULL CHECK(actor_type IN ('user', 'api_key', 'system', 'kiosk')),
actor_id INTEGER, actor_id TEXT,
actor_label TEXT, actor_label TEXT,
action TEXT NOT NULL, action TEXT NOT NULL,
resource_type TEXT, resource_type TEXT,
@ -430,8 +430,8 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- kiosk GPIO bindings ------------------------------------------------- // ---- kiosk GPIO bindings -------------------------------------------------
`CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings ( `CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
chip TEXT NOT NULL DEFAULT 'gpiochip4', chip TEXT NOT NULL DEFAULT 'gpiochip4',
pin INTEGER NOT NULL, pin INTEGER NOT NULL,
direction TEXT NOT NULL DEFAULT 'in' CHECK(direction IN ('in', 'out')), direction TEXT NOT NULL DEFAULT 'in' CHECK(direction IN ('in', 'out')),
@ -445,8 +445,8 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- kiosk_logs ---------------------------------------------------------- // ---- kiosk_logs ----------------------------------------------------------
`CREATE TABLE IF NOT EXISTS kiosk_logs ( `CREATE TABLE IF NOT EXISTS kiosk_logs (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
level TEXT NOT NULL CHECK(level IN ('debug', 'info', 'warn', 'error')), level TEXT NOT NULL CHECK(level IN ('debug', 'info', 'warn', 'error')),
message TEXT NOT NULL, message TEXT NOT NULL,
context JSONB NOT NULL DEFAULT '{}', context JSONB NOT NULL DEFAULT '{}',
@ -471,11 +471,11 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- camera_event_subscriptions --------------------------------------------- // ---- camera_event_subscriptions ---------------------------------------------
`CREATE TABLE IF NOT EXISTS camera_event_subscriptions ( `CREATE TABLE IF NOT EXISTS camera_event_subscriptions (
id SERIAL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE, camera_id TEXT NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
topic TEXT NOT NULL, topic TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'inactive' CHECK(status IN ('inactive', 'pending', 'active', 'failed')), status TEXT NOT NULL DEFAULT 'inactive' CHECK(status IN ('inactive', 'pending', 'active', 'failed')),
subscribed_by_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL, subscribed_by_kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL,
last_event_at TIMESTAMPTZ, last_event_at TIMESTAMPTZ,
error_message TEXT, error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

File diff suppressed because it is too large Load diff

View file

@ -25,10 +25,10 @@ export interface MqttBridgeLog {
} }
export interface MqttBridge { export interface MqttBridge {
publishEvent(kioskId: number | "server", topic: string, payload: Record<string, unknown>): void; publishEvent(kioskId: string | "server", topic: string, payload: Record<string, unknown>): void;
publishTelemetry(kioskId: number, payload: Record<string, unknown>): void; publishTelemetry(kioskId: string, payload: Record<string, unknown>): void;
/** Subscribe to inbound RPC. Callback gets parsed JSON or {} on parse error. */ /** Subscribe to inbound RPC. Callback gets parsed JSON or {} on parse error. */
onRpc(handler: (kioskId: number, method: string, body: Record<string, unknown>) => void): void; onRpc(handler: (kioskId: string, method: string, body: Record<string, unknown>) => void): void;
end(): void; end(): void;
} }
@ -57,7 +57,7 @@ export function initMqttBridge(config: MqttConfig, log: MqttBridgeLog): MqttBrid
const password = config.password; const password = config.password;
let client: MqttClient | undefined; let client: MqttClient | undefined;
let rpcHandlers: Array<(k: number, m: string, b: Record<string, unknown>) => void> = []; let rpcHandlers: Array<(k: string, m: string, b: Record<string, unknown>) => void> = [];
try { try {
client = mqtt.connect(url, { client = mqtt.connect(url, {
@ -97,9 +97,9 @@ export function initMqttBridge(config: MqttConfig, log: MqttBridgeLog): MqttBrid
// Expected: <prefix>/<kiosk_id>/rpc/req/<method> // Expected: <prefix>/<kiosk_id>/rpc/req/<method>
const parts = topic.split("/"); const parts = topic.split("/");
if (parts.length !== 5 || parts[0] !== prefix || parts[2] !== "rpc" || parts[3] !== "req") return; if (parts.length !== 5 || parts[0] !== prefix || parts[2] !== "rpc" || parts[3] !== "req") return;
const kioskId = Number(parts[1]); const kioskId = parts[1] ?? "";
const method = parts[4]; const method = parts[4];
if (!Number.isFinite(kioskId) || !method) return; if (!kioskId || !method) return;
let body: Record<string, unknown> = {}; let body: Record<string, unknown> = {};
try { body = JSON.parse(payload.toString("utf8")) as Record<string, unknown>; } try { body = JSON.parse(payload.toString("utf8")) as Record<string, unknown>; }
catch { /* ignore body parse errors — handler can use empty */ } catch { /* ignore body parse errors — handler can use empty */ }

View file

@ -67,7 +67,7 @@ export async function initiatePairing(
export interface PairingClaimResult { export interface PairingClaimResult {
status: "pending" | "claimed"; status: "pending" | "claimed";
kioskId?: number; kioskId?: string;
kioskName?: string; kioskName?: string;
kioskKey?: string; kioskKey?: string;
clusterKey?: string; clusterKey?: string;
@ -123,7 +123,7 @@ export interface PairingConfirmInput {
* pointed at the same kiosk id only credentials + hardware metadata roll. * pointed at the same kiosk id only credentials + hardware metadata roll.
* When set, nameOverride and initialLabels are ignored. * When set, nameOverride and initialLabels are ignored.
*/ */
replaceKioskId?: number; replaceKioskId?: string;
/** Bypass replacement-target sanity checks (hardware_model / capabilities / managed_image). */ /** Bypass replacement-target sanity checks (hardware_model / capabilities / managed_image). */
force?: boolean; force?: boolean;
} }
@ -134,7 +134,7 @@ export async function confirmPairing(
secrets: SecretsApi, secrets: SecretsApi,
input: PairingConfirmInput, input: PairingConfirmInput,
obs?: Observable, obs?: Observable,
): Promise<{ kioskId: number; kioskName: string }> { ): Promise<{ kioskId: string; kioskName: string }> {
obs?.log.info("confirm pairing for code {code}", { code: input.code }); obs?.log.info("confirm pairing for code {code}", { code: input.code });
const pc = await repo.getPairingCode(input.code); const pc = await repo.getPairingCode(input.code);
if (!pc) throw new Error("pairing code not found"); if (!pc) throw new Error("pairing code not found");
@ -145,7 +145,7 @@ export async function confirmPairing(
const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext); const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext);
const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8); const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8);
let kioskId: number; let kioskId: string;
let kioskName: string; let kioskName: string;
if (input.replaceKioskId != null) { if (input.replaceKioskId != null) {

View file

@ -16,11 +16,11 @@ export type CellContentType = "none" | "camera" | "web" | "html";
export type EntityType = "camera" | "html" | "web" | "dashboard"; export type EntityType = "camera" | "html" | "web" | "dashboard";
export interface Entity { export interface Entity {
id: number; id: string;
name: string; name: string;
type: EntityType; type: EntityType;
description: string | null; description: string | null;
camera_id: number | null; camera_id: string | null;
html_content: string | null; html_content: string | null;
web_url: string | null; web_url: string | null;
/** Node-RED dashboard tab id; populated when type === "dashboard". */ /** Node-RED dashboard tab id; populated when type === "dashboard". */
@ -33,7 +33,7 @@ export type LabelRole = "consume" | "operate";
export type EventSourceType = "onvif" | "gpio" | "synthetic" | "system"; export type EventSourceType = "onvif" | "gpio" | "synthetic" | "system";
export interface User { export interface User {
id: number; id: string;
username: string; username: string;
password_hash: string; password_hash: string;
role: UserRole; role: UserRole;
@ -50,7 +50,7 @@ export interface User {
export interface Session { export interface Session {
id: string; // hex32 id: string; // hex32
user_id: number; user_id: string;
csrf_token: string; csrf_token: string;
totp_pending: boolean; totp_pending: boolean;
user_agent: string | null; user_agent: string | null;
@ -62,7 +62,7 @@ export interface Session {
} }
export interface ApiKey { export interface ApiKey {
id: number; id: string;
name: string; name: string;
key_hash: string; key_hash: string;
key_prefix: string; // indexed for O(1) lookup key_prefix: string; // indexed for O(1) lookup
@ -84,14 +84,14 @@ export interface SetupState {
} }
export interface Display { export interface Display {
id: number; id: string;
name: string; name: string;
index: number; // unique index: number; // unique
is_primary: boolean; // deprecated — kept for backward compat, not used is_primary: boolean; // deprecated — kept for backward compat, not used
kiosk_id: number | null; // FK → kiosks; displays belong to kiosks kiosk_id: string | null; // FK → kiosks; displays belong to kiosks
width_px: number; width_px: number;
height_px: number; height_px: number;
default_layout_id: number | null; default_layout_id: string | null;
idle_timeout_seconds: number; idle_timeout_seconds: number;
sleep_timeout_seconds: number; sleep_timeout_seconds: number;
cec_enabled: boolean; cec_enabled: boolean;
@ -103,14 +103,14 @@ export interface Display {
state_check_enabled: boolean; state_check_enabled: boolean;
state_check_interval_seconds: number; state_check_interval_seconds: number;
is_enabled: boolean; is_enabled: boolean;
active_layout_id: number | null; active_layout_id: string | null;
} }
export type EventSourceMode = "auto" | "server" | string; // string = "kiosk:<id>" export type EventSourceMode = "auto" | "server" | string; // string = "kiosk:<id>"
export type EventSinkMode = "auto" | "server" | string; export type EventSinkMode = "auto" | "server" | string;
export interface Camera { export interface Camera {
id: number; id: string;
name: string; name: string;
type: CameraType; type: CameraType;
rtsp_url: string | null; rtsp_url: string | null;
@ -133,8 +133,8 @@ export interface Camera {
} }
export interface CameraStream { export interface CameraStream {
id: number; id: string;
camera_id: number; camera_id: string;
role: StreamRole; role: StreamRole;
name: string; name: string;
profile_token: string | null; profile_token: string | null;
@ -154,7 +154,7 @@ export interface CameraStream {
} }
export interface LayoutTemplate { export interface LayoutTemplate {
id: number; id: string;
name: string; name: string;
description: string | null; description: string | null;
regions: LayoutRegion[]; regions: LayoutRegion[];
@ -172,10 +172,10 @@ export interface LayoutRegion {
} }
export interface Layout { export interface Layout {
id: number; id: string;
name: string; name: string;
description: string | null; description: string | null;
template_id: number | null; // deprecated — kept nullable for backward compat template_id: string | null; // deprecated — kept nullable for backward compat
/** @deprecated Cells now own their own position. Computed from cells at read time. */ /** @deprecated Cells now own their own position. Computed from cells at read time. */
regions: LayoutRegion[]; regions: LayoutRegion[];
/** @deprecated Computed from cells: max(col + col_span). */ /** @deprecated Computed from cells: max(col + col_span). */
@ -184,18 +184,18 @@ export interface Layout {
grid_rows: number; grid_rows: number;
/** @deprecated Layouts are now standalone; use display_layouts join table. /** @deprecated Layouts are now standalone; use display_layouts join table.
* Column kept on the row for backward compat will be removed in a future migration. */ * Column kept on the row for backward compat will be removed in a future migration. */
display_id: number | null; display_id: string | null;
priority: LayoutPriority; priority: LayoutPriority;
cooling_timeout_seconds: number | null; cooling_timeout_seconds: number | null;
preload_camera_ids: number[]; preload_camera_ids: string[];
/** @deprecated Per-display defaults live on `display.default_layout_id`. */ /** @deprecated Per-display defaults live on `display.default_layout_id`. */
is_default: boolean; is_default: boolean;
resets_idle_timer: boolean; resets_idle_timer: boolean;
} }
export interface LayoutCell { export interface LayoutCell {
id: number; id: string;
layout_id: number; layout_id: string;
/** @deprecated Cells own their position via row/col/row_span/col_span now. */ /** @deprecated Cells own their position via row/col/row_span/col_span now. */
region_name: string; region_name: string;
row: number; row: number;
@ -203,18 +203,18 @@ export interface LayoutCell {
row_span: number; row_span: number;
col_span: number; col_span: number;
content_type: CellContentType; content_type: CellContentType;
camera_id: number | null; camera_id: string | null;
stream_selector: StreamSelector; stream_selector: StreamSelector;
web_url: string | null; web_url: string | null;
html_content: string | null; html_content: string | null;
cooling_timeout_seconds: number | null; cooling_timeout_seconds: number | null;
options: Record<string, unknown>; options: Record<string, unknown>;
entity_id: number | null; entity_id: string | null;
fit: "cover" | "contain" | "fill"; fit: "cover" | "contain" | "fill";
} }
export interface Kiosk { export interface Kiosk {
id: number; id: string;
name: string; name: string;
description: string | null; description: string | null;
key_hash: string; key_hash: string;
@ -227,7 +227,7 @@ export interface Kiosk {
paired_at: string | null; paired_at: string | null;
last_seen_at: string | null; last_seen_at: string | null;
last_bundle_version: string | null; last_bundle_version: string | null;
display_id: number | null; // deprecated — displays now point to kiosks via kiosk_id display_id: string | null; // deprecated — displays now point to kiosks via kiosk_id
cpu_temp_c: number | null; cpu_temp_c: number | null;
cpu_load_percent: number | null; cpu_load_percent: number | null;
fan_rpm: number | null; fan_rpm: number | null;
@ -272,10 +272,10 @@ export type AuditActorType = "user" | "api_key" | "system" | "kiosk";
export type AuditResult = "ok" | "failed"; export type AuditResult = "ok" | "failed";
export interface AuditEntry { export interface AuditEntry {
id: number; id: string;
ts: string; ts: string;
actor_type: AuditActorType; actor_type: AuditActorType;
actor_id: number | null; actor_id: string | null;
actor_label: string | null; actor_label: string | null;
action: string; action: string;
resource_type: string | null; resource_type: string | null;
@ -300,20 +300,20 @@ export interface FirmwareRelease {
signature: string; signature: string;
release_notes: string | null; release_notes: string | null;
uploaded_at: string; uploaded_at: string;
uploaded_by: number | null; uploaded_by: string | null;
yanked_at: string | null; yanked_at: string | null;
} }
export interface FirmwareRollout { export interface FirmwareRollout {
id: string; id: string;
release_id: string; release_id: string;
target_kiosk_ids: number[]; target_kiosk_ids: string[];
state: FirmwareRolloutState; state: FirmwareRolloutState;
percentage: number; percentage: number;
started_at: string | null; started_at: string | null;
finished_at: string | null; finished_at: string | null;
created_at: string; created_at: string;
created_by: number | null; created_by: string | null;
} }
export interface OsUpdateRelease { export interface OsUpdateRelease {
@ -327,20 +327,20 @@ export interface OsUpdateRelease {
bundle_format: "raucb"; bundle_format: "raucb";
release_notes: string | null; release_notes: string | null;
uploaded_at: string; uploaded_at: string;
uploaded_by: number | null; uploaded_by: string | null;
yanked_at: string | null; yanked_at: string | null;
} }
export interface OsUpdateRollout { export interface OsUpdateRollout {
id: string; id: string;
release_id: string; release_id: string;
target_kiosk_ids: number[]; target_kiosk_ids: string[];
state: OsUpdateRolloutState; state: OsUpdateRolloutState;
percentage: number; percentage: number;
started_at: string | null; started_at: string | null;
finished_at: string | null; finished_at: string | null;
created_at: string; created_at: string;
created_by: number | null; created_by: string | null;
} }
export type CloudVendor = "hikconnect" | "dahua" | "tuya" | "uniview" | "tplink"; export type CloudVendor = "hikconnect" | "dahua" | "tuya" | "uniview" | "tplink";
@ -358,7 +358,7 @@ export interface CloudAccount {
} }
export interface Label { export interface Label {
id: number; id: string;
name: string; name: string;
description: string | null; description: string | null;
color: string | null; color: string | null;
@ -366,8 +366,8 @@ export interface Label {
} }
export interface KioskLabel { export interface KioskLabel {
kiosk_id: number; kiosk_id: string;
label_id: number; label_id: string;
role: LabelRole; role: LabelRole;
} }
@ -379,7 +379,7 @@ export interface PairingCode {
issued_at: string; issued_at: string;
expires_at: string; expires_at: string;
consumed_at: string | null; consumed_at: string | null;
consumed_by_kiosk_id: number | null; consumed_by_kiosk_id: string | null;
extras: Record<string, unknown>; extras: Record<string, unknown>;
} }
@ -388,8 +388,8 @@ export type GpioPull = "up" | "down" | "none";
export type GpioEdge = "rising" | "falling" | "both"; export type GpioEdge = "rising" | "falling" | "both";
export interface KioskGpioBinding { export interface KioskGpioBinding {
id: number; id: string;
kiosk_id: number; kiosk_id: string;
chip: string; chip: string;
pin: number; pin: number;
direction: GpioDirection; direction: GpioDirection;
@ -400,9 +400,9 @@ export interface KioskGpioBinding {
} }
export interface EventLog { export interface EventLog {
id: number; id: string;
source_kiosk_id: number | null; source_kiosk_id: string | null;
source_camera_id: number | null; source_camera_id: string | null;
source_type: EventSourceType; source_type: EventSourceType;
topic: string; topic: string;
property_op: string | null; property_op: string | null;
@ -414,11 +414,11 @@ export interface EventLog {
export type EventSubscriptionStatus = "inactive" | "pending" | "active" | "failed"; export type EventSubscriptionStatus = "inactive" | "pending" | "active" | "failed";
export interface CameraEventSubscription { export interface CameraEventSubscription {
id: number; id: string;
camera_id: number; camera_id: string;
topic: string; topic: string;
status: EventSubscriptionStatus; status: EventSubscriptionStatus;
subscribed_by_kiosk_id: number | null; subscribed_by_kiosk_id: string | null;
event_source: string | null; event_source: string | null;
event_sink: string | null; event_sink: string | null;
last_event_at: string | null; last_event_at: string | null;
@ -428,8 +428,8 @@ export interface CameraEventSubscription {
export interface EventQueryFilters { export interface EventQueryFilters {
topic?: string; topic?: string;
kiosk_id?: number; kiosk_id?: string;
camera_id?: number; camera_id?: string;
source_type?: string; source_type?: string;
from?: string; from?: string;
to?: string; to?: string;
@ -440,8 +440,8 @@ export interface EventQueryFilters {
export type KioskLogLevel = "debug" | "info" | "warn" | "error"; export type KioskLogLevel = "debug" | "info" | "warn" | "error";
export interface KioskLog { export interface KioskLog {
id: number; id: string;
kiosk_id: number; kiosk_id: string;
level: KioskLogLevel; level: KioskLogLevel;
message: string; message: string;
context: Record<string, unknown>; context: Record<string, unknown>;
@ -450,7 +450,7 @@ export interface KioskLog {
} }
export interface KioskLogQueryFilters { export interface KioskLogQueryFilters {
kiosk_id: number; kiosk_id: string;
level?: KioskLogLevel; level?: KioskLogLevel;
from?: string; from?: string;
to?: string; to?: string;

View file

@ -114,8 +114,8 @@ export function OverviewPage(props: OverviewProps) {
interface CamerasProps { interface CamerasProps {
user: string; user: string;
cameras: Camera[]; cameras: Camera[];
streamCounts: Map<number, number>; streamCounts: Map<string, number>;
activeKiosks: Map<number, number>; activeKiosks: Map<string, number>;
} }
export function CamerasPage(props: CamerasProps) { export function CamerasPage(props: CamerasProps) {
@ -1165,9 +1165,9 @@ interface CameraSubscription {
interface CameraEditProps { interface CameraEditProps {
user: string; user: string;
camera: Camera; camera: Camera;
labels: Array<{ label_id: number; name: string }>; labels: Array<{ label_id: string; name: string }>;
allLabels: Label[]; allLabels: Label[];
streams: Array<{ id: number; role: string; name: string; rtsp_uri: string; rtsp_host: string | null; rtsp_port: number | null; rtsp_path: string | null }>; streams: Array<{ id: string; role: string; name: string; rtsp_uri: string; rtsp_host: string | null; rtsp_port: number | null; rtsp_path: string | null }>;
subscriptions: CameraSubscription[]; subscriptions: CameraSubscription[];
eventSubscriptions?: CameraEventSubscription[]; eventSubscriptions?: CameraEventSubscription[];
error?: string; error?: string;
@ -1180,8 +1180,8 @@ interface CameraEditProps {
* hx-target="#camera-labels-<id>" hx-swap="innerHTML". * hx-target="#camera-labels-<id>" hx-swap="innerHTML".
*/ */
export function renderCameraLabels( export function renderCameraLabels(
cameraId: number, cameraId: string,
labels: Array<{ label_id: number; name: string }>, labels: Array<{ label_id: string; name: string }>,
allLabels: Label[], allLabels: Label[],
): string { ): string {
const labelsTargetSelector = `#camera-labels-${String(cameraId)}`; const labelsTargetSelector = `#camera-labels-${String(cameraId)}`;
@ -1542,7 +1542,7 @@ export function CameraEditPage(props: CameraEditProps) {
interface KioskEditProps { interface KioskEditProps {
user: string; user: string;
kiosk: Kiosk; kiosk: Kiosk;
labels: Array<{ label_id: number; name: string; role: string }>; labels: Array<{ label_id: string; name: string; role: string }>;
allLabels: Label[]; allLabels: Label[];
displays?: Display[]; displays?: Display[];
displayLayouts?: Array<{ display: Display; layouts: LayoutType[] }>; displayLayouts?: Array<{ display: Display; layouts: LayoutType[] }>;
@ -1561,8 +1561,8 @@ interface KioskEditProps {
* hx-target="#kiosk-labels-<id>" hx-swap="innerHTML". * hx-target="#kiosk-labels-<id>" hx-swap="innerHTML".
*/ */
export function renderKioskLabels( export function renderKioskLabels(
kioskId: number, kioskId: string,
labels: Array<{ label_id: number; name: string; role: string }>, labels: Array<{ label_id: string; name: string; role: string }>,
allLabels: Label[], allLabels: Label[],
): string { ): string {
const labelsTargetSelector = `#kiosk-labels-${String(kioskId)}`; const labelsTargetSelector = `#kiosk-labels-${String(kioskId)}`;
@ -2138,7 +2138,7 @@ interface LayoutsPageProps {
user: string; user: string;
layouts: LayoutType[]; layouts: LayoutType[];
/** layout_id → number of displays the layout is attached to */ /** layout_id → number of displays the layout is attached to */
displayCounts: Map<number, number>; displayCounts: Map<string, number>;
} }
export function LayoutsPage(props: LayoutsPageProps) { export function LayoutsPage(props: LayoutsPageProps) {
@ -2305,8 +2305,8 @@ export const LAYOUT_BUILDER_CSS = `
function cellLabel( function cellLabel(
c: LayoutCell, c: LayoutCell,
entityById: Map<number, Entity>, entityById: Map<string, Entity>,
cameraById: Map<number, Camera>, cameraById: Map<string, Camera>,
): string { ): string {
if (c.entity_id != null) { if (c.entity_id != null) {
const ent = entityById.get(c.entity_id); const ent = entityById.get(c.entity_id);
@ -2331,15 +2331,15 @@ function cellGridStyle(c: LayoutCell): string {
* suitable for hx-swap="outerHTML" against itself. * suitable for hx-swap="outerHTML" against itself.
*/ */
export function renderCell( export function renderCell(
layoutId: number, layoutId: string,
c: LayoutCell, c: LayoutCell,
entities: Entity[], entities: Entity[],
cameras: Camera[], cameras: Camera[],
mode: "read" | "edit", mode: "read" | "edit",
): string { ): string {
const cameraById = new Map<number, Camera>(); const cameraById = new Map<string, Camera>();
for (const cam of cameras) cameraById.set(cam.id, cam); for (const cam of cameras) cameraById.set(cam.id, cam);
const entityById = new Map<number, Entity>(); const entityById = new Map<string, Entity>();
for (const e of entities) entityById.set(e.id, e); for (const e of entities) entityById.set(e.id, e);
const style = cellGridStyle(c); const style = cellGridStyle(c);
const cellGetUrl = `/admin/layouts/${String(layoutId)}/cells/${String(c.id)}`; const cellGetUrl = `/admin/layouts/${String(layoutId)}/cells/${String(c.id)}`;
@ -2535,7 +2535,7 @@ export function renderCell(
* in via `hx-target="#layout-grid" hx-swap="innerHTML"` after add/delete/resize. * in via `hx-target="#layout-grid" hx-swap="innerHTML"` after add/delete/resize.
*/ */
export function renderGrid( export function renderGrid(
layoutId: number, layoutId: string,
cells: LayoutCell[], cells: LayoutCell[],
entities: Entity[], entities: Entity[],
cameras: Camera[], cameras: Camera[],
@ -2721,8 +2721,8 @@ interface DisplayEditPageProps {
* `renderDefaultLayoutSelect`. * `renderDefaultLayoutSelect`.
*/ */
export function renderDisplayLayouts( export function renderDisplayLayouts(
displayId: number, displayId: string,
defaultLayoutId: number | null, defaultLayoutId: string | null,
attached: LayoutType[], attached: LayoutType[],
available: LayoutType[], available: LayoutType[],
): string { ): string {
@ -2829,7 +2829,7 @@ export function renderDisplayLayouts(
* page. The id matches the in-page select so swap-by-id works. * page. The id matches the in-page select so swap-by-id works.
*/ */
export function renderDefaultLayoutSelect( export function renderDefaultLayoutSelect(
defaultLayoutId: number | null, defaultLayoutId: string | null,
attached: LayoutType[], attached: LayoutType[],
oob: boolean = false, oob: boolean = false,
): string { ): string {