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

View file

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

View file

@ -163,9 +163,9 @@ function formValues(v: FormValue): string[] {
return v ? [v] : [];
}
function kioskOnvifSoapTransport(kioskId: number) {
function kioskOnvifSoapTransport(kioskId: 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");
}
const response = await getCoordinator().requestKiosk<{
@ -209,7 +209,7 @@ async function importDiscoveredCamera(
username: string,
password: string,
streams: DiscoverAddStream[],
): Promise<number | null> {
): Promise<string | null> {
if (streams.length === 0) return null;
const main = streams.find((s) => s.role === "main") ?? streams[0]!;
// Camera row's rtsp_url: full URL with credentials for display / backward compat.
@ -283,7 +283,7 @@ function cellsOverlap(
}
interface CellPos {
id: number;
id: string;
row: number;
col: number;
row_span: number;
@ -292,12 +292,12 @@ interface CellPos {
async function resolveOverlaps(
deps: AdminDeps,
layoutId: number,
anchorId: number,
layoutId: string,
anchorId: string,
pushAxis: "row" | "col",
): Promise<void> {
const all = await deps.repo.layoutCells(layoutId);
const positions = new Map<number, CellPos>();
const positions = new Map<string, CellPos>();
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 });
}
@ -353,8 +353,8 @@ async function resolveOverlaps(
async function shiftCellsForExpansion(
deps: AdminDeps,
layoutId: number,
cellId: number,
layoutId: string,
cellId: string,
direction: "left" | "right" | "above" | "bottom",
): Promise<void> {
const cell = await deps.repo.getLayoutCellById(cellId);
@ -379,7 +379,7 @@ async function shiftCellsForExpansion(
async function shiftCellsForInsertion(
deps: AdminDeps,
layoutId: number,
layoutId: string,
axis: "row" | "col",
fromIndex: number,
crossStart: number,
@ -541,8 +541,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/cameras", async (event) => {
const user = event.context.user!;
const cameras = await deps.repo.listCameras();
const streamCounts = new Map<number, number>();
const activeKiosks = new Map<number, number>(); // camera_id → count of kiosks rendering
const streamCounts = new Map<string, number>();
const activeKiosks = new Map<string, number>(); // camera_id → count of kiosks rendering
for (const cam of cameras) {
streamCounts.set(cam.id, (await deps.repo.listCameraStreams(cam.id)).length);
activeKiosks.set(cam.id, (await deps.repo.listKiosksRenderingCamera(cam.id)).length);
@ -643,7 +643,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
try {
const soapTransport = runner.startsWith("kiosk:")
? kioskOnvifSoapTransport(Number(runner.slice("kiosk:".length)))
? kioskOnvifSoapTransport(runner.slice("kiosk:".length))
: undefined;
const cameras = await onvifDiscover({ host, port, username, password, soapTransport });
return htmlPage(CameraDiscoverResultsPage({
@ -740,11 +740,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
errors.push("Select an entity type.");
}
let cameraId: number | null = null;
let cameraId: string | null = null;
let htmlContent: string | null = null;
let webUrl: string | null = null;
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.");
} else if (type === "html") {
htmlContent = body?.["html_content"] ?? null;
@ -776,7 +776,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/entities/:id", async (event) => {
const user = event.context.user!;
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const ent = await deps.repo.getEntityById(id);
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
return htmlPage(EntityEditPage({
@ -787,14 +787,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
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);
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
const body = await readBody<Record<string, string>>(event);
const patch: {
name?: string;
description?: string | null;
camera_id?: number | null;
camera_id?: string | null;
html_content?: string | null;
web_url?: string | null;
} = {
@ -802,7 +802,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
description: (body?.["description"] ?? "").trim() || null,
};
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") {
patch.html_content = body?.["html_content"] ?? null;
} 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) => {
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
await deps.repo.deleteEntity(id);
notifyKiosks();
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
// attempt times out). Used by the EntityEditPage "Test" preview.
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);
if (!ent || ent.type !== "camera" || ent.camera_id == null) {
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 initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
const replaceIdRaw = (body?.["replace_kiosk_id"] ?? "").trim();
const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined;
const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? replaceIdRaw : undefined;
const force = body?.["force"] === "1";
try {
@ -939,7 +939,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const user = event.context.user!;
const layouts = await deps.repo.listLayouts();
// For each layout, how many displays use it (for the list view).
const displayCounts = new Map<number, number>();
const displayCounts = new Map<string, number>();
for (const l of layouts) {
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) => {
const user = event.context.user!;
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const layout = await deps.repo.getLayoutById(id);
if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
const cells = await deps.repo.layoutCells(id);
@ -1005,7 +1005,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
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" });
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event);
const coolingStr = body?.["cooling_timeout_seconds"] ?? "";
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
// returns a 302 to the layout edit page.
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);
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) : "";
if (afterCellIdRaw && direction) {
const afterId = Number(afterCellIdRaw);
const afterId = String(afterCellIdRaw);
const cells = await deps.repo.layoutCells(layoutId);
const ref = cells.find((c) => c.id === afterId);
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).
app.get("/admin/layouts/:id/cells/:cellId", async (event) => {
const layoutId = Number(getRouterParam(event, "id"));
const cellId = Number(getRouterParam(event, "cellId"));
const layoutId = (getRouterParam(event, "id") ?? "");
const cellId = (getRouterParam(event, "cellId") ?? "");
const cell = await deps.repo.getLayoutCellById(cellId);
if (!cell || cell.layout_id !== layoutId) {
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).
app.get("/admin/layouts/:id/cells/:cellId/edit", async (event) => {
const layoutId = Number(getRouterParam(event, "id"));
const cellId = Number(getRouterParam(event, "cellId"));
const layoutId = (getRouterParam(event, "id") ?? "");
const cellId = (getRouterParam(event, "cellId") ?? "");
const cell = await deps.repo.getLayoutCellById(cellId);
if (!cell || cell.layout_id !== layoutId) {
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
// columns are managed by assignCellEntity for bundle compatibility.
app.post("/admin/layouts/:id/cells/:cellId", async (event) => {
const layoutId = Number(getRouterParam(event, "id"));
const cellId = Number(getRouterParam(event, "cellId"));
const layoutId = (getRouterParam(event, "id") ?? "");
const cellId = (getRouterParam(event, "cellId") ?? "");
const body = await readBody<Record<string, string>>(event);
const entityIdRaw = body?.["entity_id"];
const entityId =
entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null;
await deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : null);
entityIdRaw && String(entityIdRaw).trim() !== "" ? String(entityIdRaw) : null;
await deps.repo.assignCellEntity(cellId, entityId != null && entityId !== "" ? entityId : null);
// stream_selector + spans + fit are still settable on the cell.
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.
app.post("/admin/layouts/:id/cells/:cellId/resize", async (event) => {
const layoutId = Number(getRouterParam(event, "id"));
const cellId = Number(getRouterParam(event, "cellId"));
const layoutId = (getRouterParam(event, "id") ?? "");
const cellId = (getRouterParam(event, "cellId") ?? "");
const body = await readBody<Record<string, string | number>>(event);
const dim = String(body?.["dim"] ?? "");
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.
app.post("/admin/layouts/:id/cells/:cellId/move", async (event) => {
const layoutId = Number(getRouterParam(event, "id"));
const cellId = Number(getRouterParam(event, "cellId"));
const layoutId = (getRouterParam(event, "id") ?? "");
const cellId = (getRouterParam(event, "cellId") ?? "");
const body = await readBody<{ row: number; col: number }>(event);
const row = Number(body?.row ?? 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) => {
const layoutId = Number(getRouterParam(event, "id"));
const cellId = Number(getRouterParam(event, "cellId"));
const layoutId = (getRouterParam(event, "id") ?? "");
const cellId = (getRouterParam(event, "cellId") ?? "");
await deps.repo.deleteLayoutCell(cellId);
notifyKiosks();
if (isHtmxRequest(event)) {
@ -1284,7 +1284,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
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);
notifyKiosks();
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) => {
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);
notifyKiosks();
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) => {
const user = event.context.user!;
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const display = await deps.repo.getDisplayById(id);
if (!display) return new Response(null, { status: 302, headers: { location: "/admin/displays" } });
const attachedLayouts = await deps.repo.listLayoutsForDisplay(id);
@ -1325,13 +1325,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
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 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.
let validatedDefault: number | null = defaultLayoutId;
let validatedDefault: string | null = defaultLayoutId;
if (defaultLayoutId != null) {
const attached = await deps.repo.listLayoutsForDisplay(id);
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.
const renderDisplayLayoutsFragment = async (displayId: number): Promise<Response> => {
const renderDisplayLayoutsFragment = async (displayId: string): Promise<Response> => {
const display = await deps.repo.getDisplayById(displayId);
const attached = await deps.repo.listLayoutsForDisplay(displayId);
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.
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 layoutId = body?.["layout_id"] ? Number(body["layout_id"]) : null;
if (layoutId && Number.isFinite(layoutId)) {
const layoutId = body?.["layout_id"] ? String(body["layout_id"]) : null;
if (layoutId && layoutId) {
await deps.repo.attachLayoutToDisplay(displayId, layoutId);
notifyKiosks();
}
@ -1381,8 +1381,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// Detach a layout from a display.
app.post("/admin/displays/:id/layouts/:layoutId/remove", async (event) => {
const displayId = Number(getRouterParam(event, "id"));
const layoutId = Number(getRouterParam(event, "layoutId"));
const displayId = (getRouterParam(event, "id") ?? "");
const layoutId = (getRouterParam(event, "layoutId") ?? "");
await deps.repo.detachLayoutFromDisplay(displayId, layoutId);
notifyKiosks();
if (isHtmxRequest(event)) {
@ -1412,7 +1412,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
app.post("/admin/labels/:id/delete", async (event) => {
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
await deps.repo.deleteLabel(id);
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) => {
const user = event.context.user!;
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const camera = await deps.repo.getCameraById(id);
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) => {
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);
if (cam?.type === "cloud") {
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) => {
const camId = Number(getRouterParam(event, "id"));
const camId = (getRouterParam(event, "id") ?? "");
const body = await readBody<Record<string, string>>(event);
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) {
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) => {
const camId = Number(getRouterParam(event, "id"));
const camId = (getRouterParam(event, "id") ?? "");
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);
if (isHtmxRequest(event)) {
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.
// MERGE: new topics are added to the existing list, never removed.
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);
if (!cam || cam.type !== "onvif" || !cam.onvif_host) {
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";
}
const soapTransport = runner.startsWith("kiosk:")
? kioskOnvifSoapTransport(Number(runner.slice("kiosk:".length)))
? kioskOnvifSoapTransport(runner.slice("kiosk:".length))
: undefined;
try {
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.
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);
if (!cam) {
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) => {
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);
notifyKiosks();
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;");
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({
camera_id: id,
limit: 20,
@ -1672,7 +1672,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/kiosks/:id", async (event) => {
const user = event.context.user!;
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const kiosk = await deps.repo.getKioskById(id);
if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
const kioskLabels = (await deps.repo.listKioskLabels(id)).map((kl) => ({
@ -1709,7 +1709,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// ---- GPIO bindings ----------------------------------------------------
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 pin = Number(body?.["pin"]);
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) => {
const kioskId = Number(getRouterParam(event, "id"));
const bindingId = Number(getRouterParam(event, "bindingId"));
const kioskId = (getRouterParam(event, "id") ?? "");
const bindingId = (getRouterParam(event, "bindingId") ?? "");
await deps.repo.deleteGpioBinding(bindingId);
notifyKiosks();
if (isHtmxRequest(event)) {
@ -1748,7 +1748,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
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 kiosk = await deps.repo.getKioskById(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
// so the next heartbeat ships it to the kiosk.
app.post("/admin/kiosks/:id/managed-config", async (event) => {
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const kiosk = await deps.repo.getKioskById(id);
if (!kiosk) throw new Error("kiosk not found");
if (!kiosk.managed_image) throw new Error("kiosk is not running a managed image");
@ -1845,11 +1845,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
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 newLabel = (body?.["new_label"] ?? "").trim().toLowerCase();
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) {
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) => {
const kioskId = Number(getRouterParam(event, "id"));
const kioskId = (getRouterParam(event, "id") ?? "");
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);
if (isHtmxRequest(event)) {
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) => {
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);
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
// authenticated via the admin's API key.
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);
if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
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) => {
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const kiosk = await deps.repo.getKioskById(id);
if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
// WS auth: browser sends session cookie automatically on WS upgrade.
@ -2076,7 +2076,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
// ---- 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);
deps.nodered.forward("layout.changed", {
display_id: displayId,
@ -2088,13 +2088,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
};
const displayLayoutSwitch = async (event: any) => {
const displayId = Number(getRouterParam(event, "displayId"));
let layoutId = Number(getRouterParam(event, "layoutId"));
if (!Number.isFinite(layoutId) || layoutId <= 0) {
const displayId = (getRouterParam(event, "displayId") ?? "");
let layoutId = getRouterParam(event, "layoutId") ?? "";
if (!layoutId) {
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 attached = await deps.repo.listLayoutsForDisplay(displayId);
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);
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);
if (display?.kiosk_id) {
getCoordinator().sendToKiosk(display.kiosk_id, {
@ -2144,7 +2144,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
// ---- 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 displayId = displays[0]?.id ?? null;
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) => {
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
getCoordinator().sendToKiosk(id, { type: "standby" });
await emitDisplayPower(id, "standby");
await audit(deps.repo, event as any, "display.standby", { resource_type: "kiosk", resource_id: id });
@ -2172,7 +2172,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
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" });
await emitDisplayPower(id, "on");
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 ------------------------------------------------------
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 mode = body?.["mode"];
if (mode === "auto") {
@ -2216,7 +2216,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
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);
if (!cam) return jsonResponse({ error: "not_found" }, 404);
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) => {
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const display = await deps.repo.getDisplayById(id);
if (!display) return jsonResponse({ error: "not_found" }, 404);
const attachedLayouts = await deps.repo.listLayoutsForDisplay(id);
@ -2258,7 +2258,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
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);
if (!kiosk) return jsonResponse({ error: "not_found" }, 404);
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) => {
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const layout = await deps.repo.getLayoutById(id);
if (!layout) return jsonResponse({ error: "not_found" }, 404);
const cells = await deps.repo.layoutCells(id);
@ -2290,7 +2290,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
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);
if (!entity) return jsonResponse({ error: "not_found" }, 404);
return jsonResponse({ entity });
@ -2302,11 +2302,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// across all set ops. Returns the post-mutation entity.
app.post("/api/admin/displays/:id/default-layout", async (event) => {
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const raw = body["value"] ?? body["default_layout_id"];
const layoutId = raw == null || raw === "" ? null : Number(raw);
if (raw != null && raw !== "" && !Number.isFinite(layoutId)) {
const layoutId = raw == null || raw === "" ? null : String(raw);
if (raw != null && raw !== "" && !layoutId) {
return jsonResponse({ error: "invalid_value" }, 400);
}
if (layoutId != null) {
@ -2322,7 +2322,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
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 enabled = Boolean(body["value"] ?? body["enabled"]);
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) => {
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const enabled = Boolean(body["value"] ?? body["enabled"]);
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) => {
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const value = String(body["value"] ?? body["priority"] ?? "").toLowerCase();
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) => {
const id = Number(getRouterParam(event, "id"));
const id = (getRouterParam(event, "id") ?? "");
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const name = String(body["value"] ?? body["name"] ?? "").trim();
if (!name || name.length > 128) {

View file

@ -153,7 +153,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
// ---- Per-kiosk firmware settings ----------------------------------------
// POST channel + target_version (used by KioskFirmwarePanel form)
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 channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel;
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
// happens kiosk-side over the existing kiosk_key channel.
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" });
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" });
const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100);
const targetsRaw = body?.["target_kiosk_ids"];
const targets: number[] = Array.isArray(targetsRaw)
? targetsRaw.map((s) => Number(s)).filter((n) => Number.isFinite(n))
const targets: string[] = Array.isArray(targetsRaw)
? targetsRaw.map((s) => String(s)).filter((s) => s !== "")
: 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 rollout = await deps.repo.createFirmwareRollout({

View file

@ -44,7 +44,7 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void {
// ---- Per-kiosk OS-update settings ---------------------------------------
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 channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel;
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.
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 dispatched = getCoordinator().sendToKiosk(id, { type: "os_check" });
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" });
const percentage = clamp(Number(body?.["percentage"] ?? 100), 1, 100);
const targetsRaw = body?.["target_kiosk_ids"];
const targets: number[] = Array.isArray(targetsRaw)
? targetsRaw.map((s) => Number(s)).filter((n) => Number.isFinite(n))
const targets: string[] = Array.isArray(targetsRaw)
? targetsRaw.map((s) => String(s)).filter((s) => s !== "")
: 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 rollout = await deps.repo.createOsUpdateRollout({

View file

@ -557,7 +557,7 @@ function registerKioskRoutes(
// Sync displays reported by the kiosk
if (Array.isArray(body?.displays)) {
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()) {
const reportedIndex = Number.isInteger(reported.index) && reported.index! >= 0
? reported.index!
@ -659,7 +659,7 @@ function registerKioskRoutes(
const body = await readBody<{
topic: string;
source_type?: string;
camera_id?: number;
camera_id?: string;
property_op?: string;
payload?: Record<string, unknown>;
}>(event);
@ -699,9 +699,9 @@ function registerKioskRoutes(
// Side-effect: persist active layout per display so the admin UI can
// surface "currently showing X" without having to query event_log.
if (body.topic === "layout.changed") {
const displayId = Number(body.payload?.["display_id"]);
const layoutId = Number(body.payload?.["layout_id"]);
if (Number.isInteger(displayId) && Number.isInteger(layoutId)) {
const displayId = String(body.payload?.["display_id"] ?? "");
const layoutId = String(body.payload?.["layout_id"] ?? "");
if (displayId && layoutId) {
try {
await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
} catch {
@ -1080,7 +1080,7 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token);
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);
if (!camera || camera.type !== "cloud" || !camera.cloud_account_id || !camera.cloud_vendor_camera_id) {
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%
* 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 <= 0) return false;
const h = createHash("sha256")
.update(`${rolloutId}:${String(kioskId)}`)
.update(`${rolloutId}:${kioskId}`)
.digest();
const bucket = h.readUInt32BE(0) % 100;
return bucket < percentage;

View file

@ -86,14 +86,14 @@ export const EventSchemas = createEventSchemas({
// ---- Connected kiosks -------------------------------------------------------
interface ConnectedKiosk {
id: number;
id: string;
name: string;
ws: WebSocket;
}
const connectedKiosks = new Map<number, ConnectedKiosk>();
const connectedKiosks = new Map<string, ConnectedKiosk>();
const pendingRequests = new Map<string, {
kioskId: number;
kioskId: string;
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
@ -101,9 +101,9 @@ const pendingRequests = new Map<string, {
// Admin debug subscribers: admin WS connections subscribed to a kiosk's
// 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);
if (!subs) { subs = new Set(); debugSubscribers.set(kioskId, subs); }
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);
if (!subs) return;
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.
// Drain on reconnect. FIFO, cap at 100 messages per kiosk.
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 payload = JSON.stringify(message);
if (!k || k.ws.readyState !== WebSocket.OPEN) {
@ -153,7 +153,7 @@ function sendToKiosk(kioskId: number, message: object): boolean {
return true;
}
function drainOfflineQueue(kioskId: number): void {
function drainOfflineQueue(kioskId: string): void {
const q = offlineQueues.get(kioskId);
if (!q || q.length === 0) return;
const k = connectedKiosks.get(kioskId);
@ -164,7 +164,7 @@ function drainOfflineQueue(kioskId: number): void {
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();
return new Promise<T>((resolve, reject) => {
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.
if (url.pathname.startsWith("/ws/admin/debug/")) {
const kioskIdStr = url.pathname.split("/").pop() ?? "";
const kioskId = Number(kioskIdStr);
if (!Number.isInteger(kioskId) || kioskId <= 0) {
const kioskId = String(kioskIdStr);
if (!Number.isInteger(kioskId) || kioskId === "") {
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
socket.destroy();
return;
@ -454,7 +454,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
requestKiosk,
broadcastAll,
notifyBundleChanged: () => broadcastAll({ type: "reload-bundle" }),
notifyKioskBundleChanged: (kioskId: number) =>
notifyKioskBundleChanged: (kioskId: string) =>
sendToKiosk(kioskId, { type: "reload-bundle" }),
});

View file

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

View file

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

View file

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

View file

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

View file

@ -69,7 +69,7 @@ const nn = (v: unknown): number | null =>
export function rowToUser(r: Row): User {
return {
id: n(r["id"]),
id: s(r["id"]),
username: s(r["username"]),
password_hash: s(r["password_hash"]),
role: s(r["role"]) as UserRole,
@ -88,7 +88,7 @@ export function rowToUser(r: Row): User {
export function rowToSession(r: Row): Session {
return {
id: s(r["id"]),
user_id: n(r["user_id"]),
user_id: s(r["user_id"]),
csrf_token: s(r["csrf_token"]),
totp_pending: b(r["totp_pending"]),
user_agent: sn(r["user_agent"]),
@ -102,7 +102,7 @@ export function rowToSession(r: Row): Session {
export function rowToApiKey(r: Row): ApiKey {
return {
id: n(r["id"]),
id: s(r["id"]),
name: s(r["name"]),
key_hash: s(r["key_hash"]),
key_prefix: s(r["key_prefix"]),
@ -128,14 +128,14 @@ export function rowToSetupState(r: Row): SetupState {
export function rowToDisplay(r: Row): Display {
return {
id: n(r["id"]),
id: s(r["id"]),
name: s(r["name"]),
index: n(r["index"]),
is_primary: b(r["is_primary"]),
kiosk_id: nn(r["kiosk_id"]),
kiosk_id: sn(r["kiosk_id"]),
width_px: n(r["width_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"]),
sleep_timeout_seconds: n(r["sleep_timeout_seconds"]),
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_interval_seconds: n(r["state_check_interval_seconds"]),
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 {
return {
id: n(r["id"]),
id: s(r["id"]),
name: s(r["name"]),
type: s(r["type"]) as CameraType,
rtsp_url: sn(r["rtsp_url"]),
@ -178,8 +178,8 @@ export function rowToCamera(r: Row): Camera {
export function rowToCameraStream(r: Row): CameraStream {
return {
id: n(r["id"]),
camera_id: n(r["camera_id"]),
id: s(r["id"]),
camera_id: s(r["camera_id"]),
role: s(r["role"]) as StreamRole,
name: s(r["name"]),
profile_token: sn(r["profile_token"]),
@ -198,7 +198,7 @@ export function rowToCameraStream(r: Row): CameraStream {
export function rowToLayoutTemplate(r: Row): LayoutTemplate {
return {
id: n(r["id"]),
id: s(r["id"]),
name: s(r["name"]),
description: sn(r["description"]),
regions: j<LayoutRegion[]>(r["regions"], []),
@ -210,17 +210,17 @@ export function rowToLayoutTemplate(r: Row): LayoutTemplate {
export function rowToLayout(r: Row): Layout {
return {
id: n(r["id"]),
id: s(r["id"]),
name: s(r["name"]),
description: sn(r["description"]),
template_id: nn(r["template_id"]),
template_id: sn(r["template_id"]),
regions: j<LayoutRegion[]>(r["regions"], []),
grid_cols: n(r["grid_cols"]) || 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,
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"]),
resets_idle_timer: b(r["resets_idle_timer"]),
};
@ -228,32 +228,32 @@ export function rowToLayout(r: Row): Layout {
export function rowToLayoutCell(r: Row): LayoutCell {
return {
id: n(r["id"]),
layout_id: n(r["layout_id"]),
id: s(r["id"]),
layout_id: s(r["layout_id"]),
region_name: s(r["region_name"]),
row: n(r["row"]),
col: n(r["col"]),
row_span: n(r["row_span"]) || 1,
col_span: n(r["col_span"]) || 1,
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,
web_url: sn(r["web_url"]),
html_content: sn(r["html_content"]),
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
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",
};
}
export function rowToEntity(r: Row): Entity {
return {
id: n(r["id"]),
id: s(r["id"]),
name: s(r["name"]),
type: s(r["type"]) as EntityType,
description: sn(r["description"]),
camera_id: nn(r["camera_id"]),
camera_id: sn(r["camera_id"]),
html_content: sn(r["html_content"]),
web_url: sn(r["web_url"]),
dashboard_id: sn(r["dashboard_id"]),
@ -263,7 +263,7 @@ export function rowToEntity(r: Row): Entity {
export function rowToKiosk(r: Row): Kiosk {
return {
id: n(r["id"]),
id: s(r["id"]),
name: s(r["name"]),
description: sn(r["description"]),
key_hash: s(r["key_hash"]),
@ -276,7 +276,7 @@ export function rowToKiosk(r: Row): Kiosk {
paired_at: sn(r["paired_at"]),
last_seen_at: sn(r["last_seen_at"]),
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_load_percent: nn(r["cpu_load_percent"]),
fan_rpm: nn(r["fan_rpm"]),
@ -314,10 +314,10 @@ export function rowToKiosk(r: Row): Kiosk {
export function rowToAuditEntry(r: Row): AuditEntry {
return {
id: n(r["id"]),
id: s(r["id"]),
ts: s(r["ts"]),
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"]),
action: s(r["action"]),
resource_type: sn(r["resource_type"]),
@ -340,7 +340,7 @@ export function rowToFirmwareRelease(r: Row): FirmwareRelease {
signature: s(r["signature"]),
release_notes: sn(r["release_notes"]),
uploaded_at: s(r["uploaded_at"]),
uploaded_by: nn(r["uploaded_by"]),
uploaded_by: sn(r["uploaded_by"]),
yanked_at: sn(r["yanked_at"]),
};
}
@ -349,13 +349,13 @@ export function rowToFirmwareRollout(r: Row): FirmwareRollout {
return {
id: s(r["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,
percentage: n(r["percentage"]),
started_at: sn(r["started_at"]),
finished_at: sn(r["finished_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",
release_notes: sn(r["release_notes"]),
uploaded_at: s(r["uploaded_at"]),
uploaded_by: nn(r["uploaded_by"]),
uploaded_by: sn(r["uploaded_by"]),
yanked_at: sn(r["yanked_at"]),
};
}
@ -380,19 +380,19 @@ export function rowToOsUpdateRollout(r: Row): OsUpdateRollout {
return {
id: s(r["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,
percentage: n(r["percentage"]),
started_at: sn(r["started_at"]),
finished_at: sn(r["finished_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 {
return {
id: n(r["id"]),
id: s(r["id"]),
name: s(r["name"]),
description: sn(r["description"]),
color: sn(r["color"]),
@ -402,8 +402,8 @@ export function rowToLabel(r: Row): Label {
export function rowToKioskLabel(r: Row): KioskLabel {
return {
kiosk_id: n(r["kiosk_id"]),
label_id: n(r["label_id"]),
kiosk_id: s(r["kiosk_id"]),
label_id: s(r["label_id"]),
role: s(r["role"]) as LabelRole,
};
}
@ -417,7 +417,7 @@ export function rowToPairingCode(r: Row): PairingCode {
issued_at: s(r["issued_at"]),
expires_at: s(r["expires_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"], {}),
};
}
@ -426,8 +426,8 @@ export function rowToKioskGpioBinding(r: Row): KioskGpioBinding {
const pullRaw = sn(r["pull"]);
const edgeRaw = sn(r["edge"]);
return {
id: n(r["id"]),
kiosk_id: n(r["kiosk_id"]),
id: s(r["id"]),
kiosk_id: s(r["kiosk_id"]),
chip: s(r["chip"]) || "gpiochip0",
pin: n(r["pin"]),
direction: s(r["direction"]) as GpioDirection,
@ -440,9 +440,9 @@ export function rowToKioskGpioBinding(r: Row): KioskGpioBinding {
export function rowToEventLog(r: Row): EventLog {
return {
id: n(r["id"]),
source_kiosk_id: nn(r["source_kiosk_id"]),
source_camera_id: nn(r["source_camera_id"]),
id: s(r["id"]),
source_kiosk_id: sn(r["source_kiosk_id"]),
source_camera_id: sn(r["source_camera_id"]),
source_type: s(r["source_type"]) as EventSourceType,
topic: s(r["topic"]),
property_op: sn(r["property_op"]),
@ -454,8 +454,8 @@ export function rowToEventLog(r: Row): EventLog {
export function rowToKioskLog(r: Row): KioskLog {
return {
id: n(r["id"]),
kiosk_id: n(r["kiosk_id"]),
id: s(r["id"]),
kiosk_id: s(r["kiosk_id"]),
level: s(r["level"]) as KioskLogLevel,
message: s(r["message"]),
context: j<Record<string, unknown>>(r["context"], {}),
@ -480,11 +480,11 @@ export function rowToCloudAccount(r: Row): CloudAccount {
export function rowToCameraEventSubscription(r: Row): CameraEventSubscription {
return {
id: n(r["id"]),
camera_id: n(r["camera_id"]),
id: s(r["id"]),
camera_id: s(r["camera_id"]),
topic: s(r["topic"]),
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_sink: sn(r["event_sink"]),
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 (
id SERIAL PRIMARY KEY,
id TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
@ -60,7 +60,7 @@ export const PUBLIC_MIGRATIONS: readonly string[] = [
export const TENANT_MIGRATIONS: readonly string[] = [
// ---- users ---------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
id TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'operator' CHECK(role IN ('admin', 'operator')),
@ -78,7 +78,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- sessions ------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS sessions (
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,
totp_pending BOOLEAN NOT NULL DEFAULT false,
user_agent TEXT,
@ -93,7 +93,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- api_keys ------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS api_keys (
id SERIAL PRIMARY KEY,
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
key_hash 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) ----------
`CREATE TABLE IF NOT EXISTS displays (
id SERIAL PRIMARY KEY,
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
"index" INTEGER NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT false,
kiosk_id INTEGER,
kiosk_id TEXT,
width_px INTEGER NOT NULL DEFAULT 1920,
height_px INTEGER NOT NULL DEFAULT 1080,
default_layout_id INTEGER,
default_layout_id TEXT,
idle_timeout_seconds INTEGER NOT NULL DEFAULT 600,
sleep_timeout_seconds INTEGER NOT NULL DEFAULT 1800,
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_interval_seconds INTEGER NOT NULL DEFAULT 60,
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_index ON displays(kiosk_id, "index")`,
// ---- cameras -------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS cameras (
id SERIAL PRIMARY KEY,
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif', 'cloud')),
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 TABLE IF NOT EXISTS camera_streams (
id SERIAL PRIMARY KEY,
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
id TEXT NOT NULL PRIMARY KEY,
camera_id TEXT NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('main', 'sub', 'other')),
name TEXT NOT NULL,
profile_token TEXT,
@ -192,7 +192,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- kiosks (final schema — all telemetry + update columns) --------------
`CREATE TABLE IF NOT EXISTS kiosks (
id SERIAL PRIMARY KEY,
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
key_hash TEXT NOT NULL,
@ -205,7 +205,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
paired_at TIMESTAMPTZ,
last_seen_at TIMESTAMPTZ,
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,
cpu_temp_c REAL,
cpu_load_percent REAL,
@ -243,7 +243,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- layouts (final schema — no template_id, no display_id) --------------
`CREATE TABLE IF NOT EXISTS layouts (
id SERIAL PRIMARY KEY,
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
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) ----------------------------------------
`CREATE TABLE IF NOT EXISTS display_layouts (
display_id INTEGER NOT NULL REFERENCES displays(id) ON DELETE CASCADE,
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
display_id TEXT NOT NULL REFERENCES displays(id) ON DELETE CASCADE,
layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
PRIMARY KEY (display_id, layout_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_display_layouts_layout ON display_layouts(layout_id)`,
// ---- layout_cells (final schema — no region_name) ------------------------
`CREATE TABLE IF NOT EXISTS layout_cells (
id SERIAL PRIMARY KEY,
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
id TEXT NOT NULL PRIMARY KEY,
layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
"row" INTEGER NOT NULL DEFAULT 0,
col INTEGER NOT NULL DEFAULT 0,
row_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')),
camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
camera_id TEXT REFERENCES cameras(id) ON DELETE SET NULL,
stream_selector TEXT,
web_url TEXT,
html_content TEXT,
cooling_timeout_seconds INTEGER,
options JSONB NOT NULL DEFAULT '{}',
entity_id INTEGER,
entity_id TEXT,
fit TEXT NOT NULL DEFAULT 'cover'
)`,
`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 --------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS labels (
id SERIAL PRIMARY KEY,
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
color TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE TABLE IF NOT EXISTS kiosk_labels (
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('consume', 'operate')),
PRIMARY KEY (kiosk_id, label_id, role)
)`,
`CREATE TABLE IF NOT EXISTS camera_labels (
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
camera_id TEXT NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (camera_id, label_id)
)`,
`CREATE TABLE IF NOT EXISTS layout_labels (
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (layout_id, label_id)
)`,
@ -315,15 +315,15 @@ export const TENANT_MIGRATIONS: readonly string[] = [
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
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 '{}'
)`,
// ---- event_log -----------------------------------------------------------
`CREATE TABLE IF NOT EXISTS event_log (
id SERIAL PRIMARY KEY,
source_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL,
source_camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
id TEXT NOT NULL PRIMARY KEY,
source_kiosk_id TEXT REFERENCES kiosks(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')),
topic TEXT NOT NULL,
property_op TEXT,
@ -336,11 +336,11 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- entities ------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS entities (
id SERIAL PRIMARY KEY,
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK(type IN ('camera', 'html', 'web', 'dashboard')),
description TEXT,
camera_id INTEGER REFERENCES cameras(id) ON DELETE CASCADE,
camera_id TEXT REFERENCES cameras(id) ON DELETE CASCADE,
html_content TEXT,
web_url TEXT,
dashboard_id TEXT,
@ -360,7 +360,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
signature TEXT NOT NULL,
release_notes TEXT,
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,
UNIQUE(version, arch)
)`,
@ -375,7 +375,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
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)`,
@ -391,7 +391,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
bundle_format TEXT NOT NULL DEFAULT 'raucb' CHECK(bundle_format = 'raucb'),
release_notes TEXT,
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,
UNIQUE(version, compatibility)
)`,
@ -406,16 +406,16 @@ export const TENANT_MIGRATIONS: readonly string[] = [
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
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)`,
// ---- 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(),
actor_type TEXT NOT NULL CHECK(actor_type IN ('user', 'api_key', 'system', 'kiosk')),
actor_id INTEGER,
actor_id TEXT,
actor_label TEXT,
action TEXT NOT NULL,
resource_type TEXT,
@ -430,8 +430,8 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- kiosk GPIO bindings -------------------------------------------------
`CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings (
id SERIAL PRIMARY KEY,
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
id TEXT NOT NULL PRIMARY KEY,
kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
chip TEXT NOT NULL DEFAULT 'gpiochip4',
pin INTEGER NOT NULL,
direction TEXT NOT NULL DEFAULT 'in' CHECK(direction IN ('in', 'out')),
@ -445,8 +445,8 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- kiosk_logs ----------------------------------------------------------
`CREATE TABLE IF NOT EXISTS kiosk_logs (
id SERIAL PRIMARY KEY,
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
id TEXT NOT NULL PRIMARY KEY,
kiosk_id TEXT NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
level TEXT NOT NULL CHECK(level IN ('debug', 'info', 'warn', 'error')),
message TEXT NOT NULL,
context JSONB NOT NULL DEFAULT '{}',
@ -471,11 +471,11 @@ export const TENANT_MIGRATIONS: readonly string[] = [
// ---- camera_event_subscriptions ---------------------------------------------
`CREATE TABLE IF NOT EXISTS camera_event_subscriptions (
id SERIAL PRIMARY KEY,
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
id TEXT NOT NULL PRIMARY KEY,
camera_id TEXT NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
topic TEXT NOT NULL,
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,
error_message TEXT,
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 {
publishEvent(kioskId: number | "server", topic: string, payload: Record<string, unknown>): void;
publishTelemetry(kioskId: number, payload: Record<string, unknown>): void;
publishEvent(kioskId: string | "server", topic: string, 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. */
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;
}
@ -57,7 +57,7 @@ export function initMqttBridge(config: MqttConfig, log: MqttBridgeLog): MqttBrid
const password = config.password;
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 {
client = mqtt.connect(url, {
@ -97,9 +97,9 @@ export function initMqttBridge(config: MqttConfig, log: MqttBridgeLog): MqttBrid
// Expected: <prefix>/<kiosk_id>/rpc/req/<method>
const parts = topic.split("/");
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];
if (!Number.isFinite(kioskId) || !method) return;
if (!kioskId || !method) return;
let body: Record<string, unknown> = {};
try { body = JSON.parse(payload.toString("utf8")) as Record<string, unknown>; }
catch { /* ignore body parse errors — handler can use empty */ }

View file

@ -67,7 +67,7 @@ export async function initiatePairing(
export interface PairingClaimResult {
status: "pending" | "claimed";
kioskId?: number;
kioskId?: string;
kioskName?: string;
kioskKey?: string;
clusterKey?: string;
@ -123,7 +123,7 @@ export interface PairingConfirmInput {
* pointed at the same kiosk id only credentials + hardware metadata roll.
* When set, nameOverride and initialLabels are ignored.
*/
replaceKioskId?: number;
replaceKioskId?: string;
/** Bypass replacement-target sanity checks (hardware_model / capabilities / managed_image). */
force?: boolean;
}
@ -134,7 +134,7 @@ export async function confirmPairing(
secrets: SecretsApi,
input: PairingConfirmInput,
obs?: Observable,
): Promise<{ kioskId: number; kioskName: string }> {
): Promise<{ kioskId: string; kioskName: string }> {
obs?.log.info("confirm pairing for code {code}", { code: input.code });
const pc = await repo.getPairingCode(input.code);
if (!pc) throw new Error("pairing code not found");
@ -145,7 +145,7 @@ export async function confirmPairing(
const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext);
const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8);
let kioskId: number;
let kioskId: string;
let kioskName: string;
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 interface Entity {
id: number;
id: string;
name: string;
type: EntityType;
description: string | null;
camera_id: number | null;
camera_id: string | null;
html_content: string | null;
web_url: string | null;
/** 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 interface User {
id: number;
id: string;
username: string;
password_hash: string;
role: UserRole;
@ -50,7 +50,7 @@ export interface User {
export interface Session {
id: string; // hex32
user_id: number;
user_id: string;
csrf_token: string;
totp_pending: boolean;
user_agent: string | null;
@ -62,7 +62,7 @@ export interface Session {
}
export interface ApiKey {
id: number;
id: string;
name: string;
key_hash: string;
key_prefix: string; // indexed for O(1) lookup
@ -84,14 +84,14 @@ export interface SetupState {
}
export interface Display {
id: number;
id: string;
name: string;
index: number; // unique
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;
height_px: number;
default_layout_id: number | null;
default_layout_id: string | null;
idle_timeout_seconds: number;
sleep_timeout_seconds: number;
cec_enabled: boolean;
@ -103,14 +103,14 @@ export interface Display {
state_check_enabled: boolean;
state_check_interval_seconds: number;
is_enabled: boolean;
active_layout_id: number | null;
active_layout_id: string | null;
}
export type EventSourceMode = "auto" | "server" | string; // string = "kiosk:<id>"
export type EventSinkMode = "auto" | "server" | string;
export interface Camera {
id: number;
id: string;
name: string;
type: CameraType;
rtsp_url: string | null;
@ -133,8 +133,8 @@ export interface Camera {
}
export interface CameraStream {
id: number;
camera_id: number;
id: string;
camera_id: string;
role: StreamRole;
name: string;
profile_token: string | null;
@ -154,7 +154,7 @@ export interface CameraStream {
}
export interface LayoutTemplate {
id: number;
id: string;
name: string;
description: string | null;
regions: LayoutRegion[];
@ -172,10 +172,10 @@ export interface LayoutRegion {
}
export interface Layout {
id: number;
id: string;
name: string;
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. */
regions: LayoutRegion[];
/** @deprecated Computed from cells: max(col + col_span). */
@ -184,18 +184,18 @@ export interface Layout {
grid_rows: number;
/** @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. */
display_id: number | null;
display_id: string | null;
priority: LayoutPriority;
cooling_timeout_seconds: number | null;
preload_camera_ids: number[];
preload_camera_ids: string[];
/** @deprecated Per-display defaults live on `display.default_layout_id`. */
is_default: boolean;
resets_idle_timer: boolean;
}
export interface LayoutCell {
id: number;
layout_id: number;
id: string;
layout_id: string;
/** @deprecated Cells own their position via row/col/row_span/col_span now. */
region_name: string;
row: number;
@ -203,18 +203,18 @@ export interface LayoutCell {
row_span: number;
col_span: number;
content_type: CellContentType;
camera_id: number | null;
camera_id: string | null;
stream_selector: StreamSelector;
web_url: string | null;
html_content: string | null;
cooling_timeout_seconds: number | null;
options: Record<string, unknown>;
entity_id: number | null;
entity_id: string | null;
fit: "cover" | "contain" | "fill";
}
export interface Kiosk {
id: number;
id: string;
name: string;
description: string | null;
key_hash: string;
@ -227,7 +227,7 @@ export interface Kiosk {
paired_at: string | null;
last_seen_at: 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_load_percent: number | null;
fan_rpm: number | null;
@ -272,10 +272,10 @@ export type AuditActorType = "user" | "api_key" | "system" | "kiosk";
export type AuditResult = "ok" | "failed";
export interface AuditEntry {
id: number;
id: string;
ts: string;
actor_type: AuditActorType;
actor_id: number | null;
actor_id: string | null;
actor_label: string | null;
action: string;
resource_type: string | null;
@ -300,20 +300,20 @@ export interface FirmwareRelease {
signature: string;
release_notes: string | null;
uploaded_at: string;
uploaded_by: number | null;
uploaded_by: string | null;
yanked_at: string | null;
}
export interface FirmwareRollout {
id: string;
release_id: string;
target_kiosk_ids: number[];
target_kiosk_ids: string[];
state: FirmwareRolloutState;
percentage: number;
started_at: string | null;
finished_at: string | null;
created_at: string;
created_by: number | null;
created_by: string | null;
}
export interface OsUpdateRelease {
@ -327,20 +327,20 @@ export interface OsUpdateRelease {
bundle_format: "raucb";
release_notes: string | null;
uploaded_at: string;
uploaded_by: number | null;
uploaded_by: string | null;
yanked_at: string | null;
}
export interface OsUpdateRollout {
id: string;
release_id: string;
target_kiosk_ids: number[];
target_kiosk_ids: string[];
state: OsUpdateRolloutState;
percentage: number;
started_at: string | null;
finished_at: string | null;
created_at: string;
created_by: number | null;
created_by: string | null;
}
export type CloudVendor = "hikconnect" | "dahua" | "tuya" | "uniview" | "tplink";
@ -358,7 +358,7 @@ export interface CloudAccount {
}
export interface Label {
id: number;
id: string;
name: string;
description: string | null;
color: string | null;
@ -366,8 +366,8 @@ export interface Label {
}
export interface KioskLabel {
kiosk_id: number;
label_id: number;
kiosk_id: string;
label_id: string;
role: LabelRole;
}
@ -379,7 +379,7 @@ export interface PairingCode {
issued_at: string;
expires_at: string;
consumed_at: string | null;
consumed_by_kiosk_id: number | null;
consumed_by_kiosk_id: string | null;
extras: Record<string, unknown>;
}
@ -388,8 +388,8 @@ export type GpioPull = "up" | "down" | "none";
export type GpioEdge = "rising" | "falling" | "both";
export interface KioskGpioBinding {
id: number;
kiosk_id: number;
id: string;
kiosk_id: string;
chip: string;
pin: number;
direction: GpioDirection;
@ -400,9 +400,9 @@ export interface KioskGpioBinding {
}
export interface EventLog {
id: number;
source_kiosk_id: number | null;
source_camera_id: number | null;
id: string;
source_kiosk_id: string | null;
source_camera_id: string | null;
source_type: EventSourceType;
topic: string;
property_op: string | null;
@ -414,11 +414,11 @@ export interface EventLog {
export type EventSubscriptionStatus = "inactive" | "pending" | "active" | "failed";
export interface CameraEventSubscription {
id: number;
camera_id: number;
id: string;
camera_id: string;
topic: string;
status: EventSubscriptionStatus;
subscribed_by_kiosk_id: number | null;
subscribed_by_kiosk_id: string | null;
event_source: string | null;
event_sink: string | null;
last_event_at: string | null;
@ -428,8 +428,8 @@ export interface CameraEventSubscription {
export interface EventQueryFilters {
topic?: string;
kiosk_id?: number;
camera_id?: number;
kiosk_id?: string;
camera_id?: string;
source_type?: string;
from?: string;
to?: string;
@ -440,8 +440,8 @@ export interface EventQueryFilters {
export type KioskLogLevel = "debug" | "info" | "warn" | "error";
export interface KioskLog {
id: number;
kiosk_id: number;
id: string;
kiosk_id: string;
level: KioskLogLevel;
message: string;
context: Record<string, unknown>;
@ -450,7 +450,7 @@ export interface KioskLog {
}
export interface KioskLogQueryFilters {
kiosk_id: number;
kiosk_id: string;
level?: KioskLogLevel;
from?: string;
to?: string;

View file

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