diff --git a/package-lock.json b/package-lock.json index d034010..15d9c15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -738,6 +738,18 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/onvif": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/onvif/-/onvif-0.8.1.tgz", + "integrity": "sha512-D0VSuTQutZWQsaWWvh7acbengYNJew25kXpXc/stJc6/xYx2MwU1tkIZA23bu12hr5MxstSQ55decY7we2/LWw==", + "license": "MIT", + "dependencies": { + "xml2js": "^0.6.2" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/otpauth": { "version": "9.5.1", "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz", @@ -788,6 +800,15 @@ "integrity": "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -910,6 +931,28 @@ } } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/yaml": { "version": "2.8.4", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", @@ -936,6 +979,7 @@ "argon2": "^0.44.0", "h3": "^2.0.1-rc.22", "jsx-htmx": "^2.0.2", + "onvif": "^0.8.1", "otpauth": "^9.5.1", "ws": "^8.20.0" }, diff --git a/server/package.json b/server/package.json index 0ab7d24..c64f2c4 100644 --- a/server/package.json +++ b/server/package.json @@ -27,6 +27,7 @@ "argon2": "^0.44.0", "h3": "^2.0.1-rc.22", "jsx-htmx": "^2.0.2", + "onvif": "^0.8.1", "otpauth": "^9.5.1", "ws": "^8.20.0" }, diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index a465657..6a9103f 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -11,6 +11,11 @@ import { CamerasPage, CameraNewPage, CameraEditPage, + CameraDiscoverPage, + CameraDiscoverResultsPage, + EntitiesPage, + EntityNewPage, + EntityEditPage, KiosksPage, KioskEditPage, LabelsPage, @@ -22,6 +27,7 @@ import { renderCell, renderGrid, } from "../../web-templates/admin-pages.js"; +import { discover as onvifDiscover } from "../../shared/onvif.js"; function htmlFragment(markup: unknown): Response { return new Response(String(markup), { @@ -98,7 +104,6 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const user = event.context.user!; const body = await readBody>(event); const name = (body?.["name"] ?? "").trim(); - const type = body?.["type"] as "rtsp" | "onvif" | undefined; const errors: string[] = []; if (!name || name.length > 128) { @@ -107,35 +112,18 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { errors.push("Camera name already in use."); } - if (type !== "rtsp" && type !== "onvif") { - errors.push("Select camera type."); - } - + const host = (body?.["rtsp_host"] ?? "").trim(); + const port = (body?.["rtsp_port"] ?? "554").trim(); + const path = (body?.["rtsp_path"] ?? "").trim(); + const username = (body?.["rtsp_username"] ?? "").trim(); + const pass = body?.["rtsp_password"] ?? ""; let rtspUrl: string | undefined; - let onvifHost: string | undefined; - let onvifPort: number | undefined; - let onvifUser: string | undefined; - let onvifPass: string | undefined; - - if (type === "rtsp") { - const host = (body?.["rtsp_host"] ?? "").trim(); - const port = (body?.["rtsp_port"] ?? "554").trim(); - const path = (body?.["rtsp_path"] ?? "").trim(); - const user = (body?.["rtsp_username"] ?? "").trim(); - const pass = body?.["rtsp_password"] ?? ""; - if (!host) { - errors.push("RTSP host required."); - } else { - const userPart = user ? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@` : ""; - const pathPart = path.startsWith("/") ? path : `/${path}`; - rtspUrl = `rtsp://${userPart}${host}:${port}${pathPart}`; - } - } else if (type === "onvif") { - onvifHost = (body?.["onvif_host"] ?? "").trim(); - onvifPort = parseInt(body?.["onvif_port"] ?? "80", 10); - onvifUser = (body?.["onvif_username"] ?? "").trim(); - onvifPass = body?.["onvif_password"] ?? ""; - if (!onvifHost) errors.push("ONVIF host required."); + if (!host) { + errors.push("RTSP host required."); + } else { + const userPart = username ? `${encodeURIComponent(username)}:${encodeURIComponent(pass)}@` : ""; + const pathPart = path.startsWith("/") ? path : `/${path}`; + rtspUrl = `rtsp://${userPart}${host}:${port}${pathPart}`; } if (errors.length > 0) { @@ -148,16 +136,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const cam = deps.repo.createCamera({ name, - type: type!, + type: "rtsp", rtsp_url: rtspUrl ?? null, - onvif_host: onvifHost ?? null, - onvif_port: onvifPort ?? null, - onvif_username: onvifUser ?? null, - onvif_password: onvifPass ?? null, }); - // Create default main stream for RTSP cameras - if (type === "rtsp" && rtspUrl) { + if (rtspUrl) { deps.repo.createCameraStream({ camera_id: cam.id, role: "main", @@ -165,6 +148,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { rtsp_uri: rtspUrl, }); } + notifyKiosks(); return new Response(null, { status: 302, @@ -172,6 +156,204 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); }); + // ---- Camera ONVIF discovery ------------------------------------------------ + + app.get("/admin/cameras/discover", (event) => { + const user = event.context.user!; + return htmlPage(CameraDiscoverPage({ user: user.username })); + }); + + app.post("/admin/cameras/discover", async (event) => { + const user = event.context.user!; + const body = await readBody>(event); + const host = (body?.["host"] ?? "").trim(); + const port = parseInt(body?.["port"] ?? "80", 10) || 80; + const username = (body?.["username"] ?? "").trim(); + const password = body?.["password"] ?? ""; + + if (!host) { + return htmlPage(CameraDiscoverPage({ + user: user.username, + error: "Host required.", + values: body, + })); + } + + try { + const profiles = await onvifDiscover({ host, port, username, password }); + return htmlPage(CameraDiscoverResultsPage({ + user: user.username, + host, + profiles, + })); + } catch (err) { + return htmlPage(CameraDiscoverPage({ + user: user.username, + error: `Discovery failed: ${(err as Error).message}`, + values: body, + })); + } + }); + + app.post("/admin/cameras/discover/add", async (event) => { + const body = await readBody>(event); + const rawName = (body?.["name"] ?? "").trim() || "ONVIF camera"; + const rtspUrl = (body?.["rtsp_url"] ?? "").trim(); + const encoding = (body?.["encoding"] ?? "").trim() || null; + const profileToken = (body?.["profile_token"] ?? "").trim() || null; + const width = body?.["width"] ? Number(body["width"]) : null; + const height = body?.["height"] ? Number(body["height"]) : null; + const framerate = body?.["framerate"] ? Number(body["framerate"]) : null; + + if (!rtspUrl) { + return new Response(null, { status: 302, headers: { location: "/admin/cameras/discover" } }); + } + + // Resolve a unique camera name + let name = rawName; + if (deps.repo.getCameraByName(name)) { + let i = 2; + while (deps.repo.getCameraByName(`${rawName} (${String(i)})`)) i += 1; + name = `${rawName} (${String(i)})`; + } + + const cam = deps.repo.createCamera({ + name, + type: "rtsp", + rtsp_url: rtspUrl, + }); + deps.repo.createCameraStream({ + camera_id: cam.id, + role: "main", + name: "Main", + rtsp_uri: rtspUrl, + profile_token: profileToken, + width: Number.isFinite(width) ? width : null, + height: Number.isFinite(height) ? height : null, + encoding, + framerate: Number.isFinite(framerate) ? framerate : null, + is_discovered: true, + }); + notifyKiosks(); + + return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); + }); + + // ---- Entities -------------------------------------------------------------- + + app.get("/admin/entities", (event) => { + const user = event.context.user!; + return htmlPage(EntitiesPage({ + user: user.username, + entities: deps.repo.listEntities(), + })); + }); + + app.get("/admin/entities/new", (event) => { + const user = event.context.user!; + return htmlPage(EntityNewPage({ + user: user.username, + cameras: deps.repo.listCameras(), + })); + }); + + app.post("/admin/entities/new", async (event) => { + const user = event.context.user!; + const body = await readBody>(event); + const name = (body?.["name"] ?? "").trim(); + const type = body?.["type"] as "camera" | "html" | "web" | undefined; + const description = (body?.["description"] ?? "").trim() || null; + const errors: string[] = []; + + if (!name || name.length > 128) { + errors.push("Name required (max 128 chars)."); + } else if (deps.repo.getEntityByName(name)) { + errors.push("Entity name already in use."); + } + if (type !== "camera" && type !== "html" && type !== "web") { + errors.push("Select an entity type."); + } + + let cameraId: number | null = null; + let htmlContent: string | null = null; + let webUrl: string | null = null; + if (type === "camera") { + cameraId = body?.["camera_id"] ? Number(body["camera_id"]) : null; + if (!cameraId) errors.push("Pick a camera."); + } else if (type === "html") { + htmlContent = body?.["html_content"] ?? null; + } else if (type === "web") { + webUrl = (body?.["web_url"] ?? "").trim() || null; + if (!webUrl) errors.push("URL required."); + } + + if (errors.length > 0) { + return htmlPage(EntityNewPage({ + user: user.username, + cameras: deps.repo.listCameras(), + error: errors.join(" "), + values: body, + })); + } + + deps.repo.createEntity({ + name, + type: type!, + description, + camera_id: cameraId, + html_content: htmlContent, + web_url: webUrl, + }); + notifyKiosks(); + return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); + }); + + app.get("/admin/entities/:id", (event) => { + const user = event.context.user!; + const id = Number(getRouterParam(event, "id")); + const ent = deps.repo.getEntityById(id); + if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); + return htmlPage(EntityEditPage({ + user: user.username, + entity: ent, + cameras: deps.repo.listCameras(), + })); + }); + + app.post("/admin/entities/:id", async (event) => { + const id = Number(getRouterParam(event, "id")); + const ent = deps.repo.getEntityById(id); + if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); + const body = await readBody>(event); + const patch: { + name?: string; + description?: string | null; + camera_id?: number | null; + html_content?: string | null; + web_url?: string | null; + } = { + name: (body?.["name"] ?? ent.name).trim(), + description: (body?.["description"] ?? "").trim() || null, + }; + if (ent.type === "camera") { + patch.camera_id = body?.["camera_id"] ? Number(body["camera_id"]) : null; + } else if (ent.type === "html") { + patch.html_content = body?.["html_content"] ?? null; + } else if (ent.type === "web") { + patch.web_url = (body?.["web_url"] ?? "").trim() || null; + } + deps.repo.updateEntity(id, patch); + notifyKiosks(); + return new Response(null, { status: 302, headers: { location: `/admin/entities/${String(id)}` } }); + }); + + app.post("/admin/entities/:id/delete", (event) => { + const id = Number(getRouterParam(event, "id")); + deps.repo.deleteEntity(id); + notifyKiosks(); + return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); + }); + // ---- Kiosks --------------------------------------------------------------- app.get("/admin/kiosks", (event) => { @@ -266,6 +448,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } }); const cells = deps.repo.layoutCells(id); const cameras = deps.repo.listCameras(); + const entities = deps.repo.listEntities(); const displays = deps.repo.listDisplaysForLayout(id); return htmlPage(LayoutEditPage({ user: user.username, @@ -273,6 +456,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { displays, cells, cameras, + entities, })); }); @@ -314,7 +498,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (!ref) { if (isHtmxRequest(event)) { const cameras = deps.repo.listCameras(); - return htmlFragment(renderGrid(layoutId, cells, cameras)); + const entities = deps.repo.listEntities(); + return htmlFragment(renderGrid(layoutId, cells, entities, cameras)); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); } @@ -359,15 +544,15 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { col, row_span: 1, col_span: 1, - content_type: "html", - html_content: null, + entity_id: null, }); notifyKiosks(); if (isHtmxRequest(event)) { const cells = deps.repo.layoutCells(layoutId); const cameras = deps.repo.listCameras(); - return htmlFragment(renderGrid(layoutId, cells, cameras)); + const entities = deps.repo.listEntities(); + return htmlFragment(renderGrid(layoutId, cells, entities, cameras)); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); @@ -381,7 +566,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response("Not Found", { status: 404 }); } const cameras = deps.repo.listCameras(); - return htmlFragment(renderCell(layoutId, cell, cameras, "read")); + const entities = deps.repo.listEntities(); + return htmlFragment(renderCell(layoutId, cell, entities, cameras, "read")); }); // GET a single cell in edit mode (htmx swap target for cell click). @@ -393,47 +579,47 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response("Not Found", { status: 404 }); } const cameras = deps.repo.listCameras(); - return htmlFragment(renderCell(layoutId, cell, cameras, "edit")); + const entities = deps.repo.listEntities(); + return htmlFragment(renderCell(layoutId, cell, entities, cameras, "edit")); }); - // Update a cell's content assignment + dimensions. - // For htmx requests, returns the updated cell HTML (read mode) for outerHTML - // swap onto the cell element. For normal POSTs, returns 302. + // Update a cell's entity binding + dimensions. Legacy content_type/web/html + // columns are managed by assignCellEntity for bundle compatibility. app.post("/admin/layouts/:id/cells/:cellId", async (event) => { const layoutId = Number(getRouterParam(event, "id")); const cellId = Number(getRouterParam(event, "cellId")); const body = await readBody>(event); - const contentType = (body?.["content_type"] ?? "html") as "camera" | "web" | "html"; - const patch: Record = { - content_type: contentType, - camera_id: contentType === "camera" && body?.["camera_id"] ? Number(body["camera_id"]) : null, - stream_selector: contentType === "camera" - ? ((body?.["stream_selector"] as "auto" | "main" | "sub") ?? "auto") - : "auto", - web_url: contentType === "web" ? (body?.["web_url"] ?? null) : null, - html_content: contentType === "html" ? (body?.["html_content"] ?? null) : null, - }; + const entityIdRaw = body?.["entity_id"]; + const entityId = + entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null; + deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : null); + // stream_selector + spans are still settable on the cell. + const dimsPatch: Record = {}; + const streamSelector = body?.["stream_selector"]; + if (streamSelector === "auto" || streamSelector === "main" || streamSelector === "sub") { + dimsPatch["stream_selector"] = streamSelector; + } const colSpanRaw = body?.["col_span"]; const rowSpanRaw = body?.["row_span"]; if (colSpanRaw != null && String(colSpanRaw).trim() !== "") { - const v = Math.max(1, Number(colSpanRaw) || 1); - patch["col_span"] = v; + dimsPatch["col_span"] = Math.max(1, Number(colSpanRaw) || 1); } if (rowSpanRaw != null && String(rowSpanRaw).trim() !== "") { - const v = Math.max(1, Number(rowSpanRaw) || 1); - patch["row_span"] = v; + dimsPatch["row_span"] = Math.max(1, Number(rowSpanRaw) || 1); + } + if (Object.keys(dimsPatch).length > 0) { + deps.repo.updateLayoutCell(cellId, dimsPatch as any); } - - deps.repo.updateLayoutCell(cellId, patch as any); notifyKiosks(); if (isHtmxRequest(event)) { const cell = deps.repo.getLayoutCellById(cellId); if (!cell) return new Response("", { headers: { "content-type": "text/html; charset=utf-8" } }); const cameras = deps.repo.listCameras(); - return htmlFragment(renderCell(layoutId, cell, cameras, "read")); + const entities = deps.repo.listEntities(); + return htmlFragment(renderCell(layoutId, cell, entities, cameras, "read")); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); @@ -458,8 +644,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const cells = deps.repo.layoutCells(layoutId); const cameras = deps.repo.listCameras(); + const entities = deps.repo.listEntities(); if (isHtmxRequest(event)) { - return htmlFragment(renderGrid(layoutId, cells, cameras)); + return htmlFragment(renderGrid(layoutId, cells, entities, cameras)); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); @@ -472,7 +659,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (isHtmxRequest(event)) { const cells = deps.repo.layoutCells(layoutId); const cameras = deps.repo.listCameras(); - return htmlFragment(renderGrid(layoutId, cells, cameras)); + const entities = deps.repo.listEntities(); + return htmlFragment(renderGrid(layoutId, cells, entities, cameras)); } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index 710b48e..2a41ef1 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -14,6 +14,8 @@ import type { CellContentType, DesiredPowerState, Display, + Entity, + EntityType, EventLog, EventSourceType, Kiosk, @@ -205,6 +207,20 @@ export function rowToLayoutCell(r: Row): LayoutCell { html_content: sn(r["html_content"]), cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]), options: j>(r["options"], {}), + entity_id: nn(r["entity_id"]), + }; +} + +export function rowToEntity(r: Row): Entity { + return { + id: n(r["id"]), + name: s(r["name"]), + type: s(r["type"]) as EntityType, + description: sn(r["description"]), + camera_id: nn(r["camera_id"]), + html_content: sn(r["html_content"]), + web_url: sn(r["web_url"]), + created_at: s(r["created_at"]), }; } diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index e338703..fd789b2 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -441,4 +441,100 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ // Drop layout_templates entirely — concept removed `DROP TABLE IF EXISTS layout_templates`, + + // ---- v0.8: entities — unified content pool for layout cells ----------------- + // Admin creates a reusable "entity" (camera reference, html snippet, web page) + // once and binds it to one or more layout cells. Cameras get an automatic + // mirror entity so existing layouts keep working. + `CREATE TABLE IF NOT EXISTS entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL CHECK(type IN ('camera', 'html', 'web')), + description TEXT, + camera_id INTEGER REFERENCES cameras(id) ON DELETE CASCADE, + html_content TEXT, + web_url TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ) STRICT`, + `CREATE INDEX IF NOT EXISTS idx_entities_camera ON entities(camera_id)`, + + (db: DatabaseSync) => { + addColumnIfNotExists(db, "layout_cells", "entity_id", "INTEGER REFERENCES entities(id) ON DELETE SET NULL"); + }, + `CREATE INDEX IF NOT EXISTS idx_layout_cells_entity ON layout_cells(entity_id)`, + + // Backfill 1: ensure every camera has a mirror entity (name = camera.name) + (db: DatabaseSync) => { + const cams = db.prepare(`SELECT id, name FROM cameras`).all() as Array<{ id: number; name: string }>; + const has = db.prepare(`SELECT id FROM entities WHERE type = 'camera' AND camera_id = ?`); + const ins = db.prepare( + `INSERT OR IGNORE INTO entities (name, type, camera_id) VALUES (?, 'camera', ?)`, + ); + for (const c of cams) { + const existing = has.get(c.id); + if (existing) continue; + // Resolve name collision by appending the camera id + const taken = db.prepare(`SELECT id FROM entities WHERE name = ?`).get(c.name); + const useName = taken ? `${c.name} (cam #${String(c.id)})` : c.name; + ins.run(useName, c.id); + } + }, + + // Backfill 2: for each cell, set entity_id based on legacy content_type fields + (db: DatabaseSync) => { + const cells = db + .prepare( + `SELECT id, content_type, camera_id, html_content, web_url, entity_id + FROM layout_cells + WHERE entity_id IS NULL`, + ) + .all() as Array<{ + id: number; + content_type: string; + camera_id: number | null; + html_content: string | null; + web_url: string | null; + entity_id: number | null; + }>; + + const findCameraEntity = db.prepare(`SELECT id FROM entities WHERE type = 'camera' AND camera_id = ?`); + const insertEntity = db.prepare( + `INSERT INTO entities (name, type, html_content, web_url) VALUES (?, ?, ?, ?)`, + ); + const setCellEntity = db.prepare(`UPDATE layout_cells SET entity_id = ? WHERE id = ?`); + const nameExists = db.prepare(`SELECT 1 FROM entities WHERE name = ?`); + + let autoCounter = 1; + function uniqueName(base: string): string { + // Find a unique entity name for the auto-created snippet + let candidate = base; + while (nameExists.get(candidate)) { + candidate = `${base} ${String(autoCounter)}`; + autoCounter += 1; + } + autoCounter += 1; + return candidate; + } + + for (const cell of cells) { + if (cell.content_type === "camera" && cell.camera_id != null) { + const ent = findCameraEntity.get(cell.camera_id) as { id: number } | undefined; + if (ent) setCellEntity.run(ent.id, cell.id); + continue; + } + if (cell.content_type === "html" && cell.html_content) { + const name = uniqueName(`Cell ${String(cell.id)} HTML`); + const r = insertEntity.run(name, "html", cell.html_content, null); + setCellEntity.run(Number(r.lastInsertRowid), cell.id); + continue; + } + if (cell.content_type === "web" && cell.web_url) { + const name = uniqueName(`Cell ${String(cell.id)} Web`); + const r = insertEntity.run(name, "web", null, cell.web_url); + setCellEntity.run(Number(r.lastInsertRowid), cell.id); + continue; + } + // empty cell — leave entity_id null + } + }, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 3cea510..a68c55d 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -18,6 +18,8 @@ import type { CameraStream, CameraType, Display, + Entity, + EntityType, EventLog, EventSourceType, Kiosk, @@ -40,6 +42,7 @@ import { rowToCamera, rowToCameraStream, rowToDisplay, + rowToEntity, rowToEventLog, rowToKiosk, rowToLabel, @@ -545,30 +548,48 @@ export class Repository { col: number; row_span?: number; col_span?: number; - content_type: string; + content_type?: string; camera_id?: number | null; stream_selector?: string | null; web_url?: string | null; html_content?: string | null; cooling_timeout_seconds?: number | null; options?: Record; + entity_id?: number | null; }): LayoutCell { + // Resolve content fields from the entity (if given). The legacy columns + // remain populated for backward-compatible bundle generation. + let contentType = input.content_type ?? "html"; + let cameraId: number | null = input.camera_id ?? null; + let webUrl: string | null = input.web_url ?? null; + let htmlContent: string | null = input.html_content ?? null; + if (input.entity_id != null) { + const ent = this.getEntityById(input.entity_id); + if (ent) { + contentType = ent.type; + cameraId = ent.type === "camera" ? ent.camera_id : null; + webUrl = ent.type === "web" ? ent.web_url : null; + htmlContent = ent.type === "html" ? ent.html_content : null; + } + } + const result = this.prep( - `INSERT INTO layout_cells (layout_id, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO layout_cells (layout_id, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options, entity_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( input.layout_id, input.row, input.col, input.row_span ?? 1, input.col_span ?? 1, - input.content_type, - input.camera_id ?? null, + contentType, + cameraId, input.stream_selector ?? "auto", - input.web_url ?? null, - input.html_content ?? null, + webUrl, + htmlContent, input.cooling_timeout_seconds ?? null, J(input.options ?? {}), + input.entity_id ?? null, ); const id = Number(result.lastInsertRowid); void this.notify("layout_cells", "create", id); @@ -577,6 +598,50 @@ export class Repository { return rowToLayoutCell(r as Record); } + /** + * Assign (or clear) the entity for a cell. Also mirrors the resolved entity's + * type/camera/url/html into the legacy cell columns so bundle generation stays + * compatible with the existing kiosk. + */ + assignCellEntity(cellId: number, entityId: number | null): void { + if (entityId == null) { + this.db + .prepare( + `UPDATE layout_cells + SET entity_id = NULL, + content_type = 'html', + camera_id = NULL, + web_url = NULL, + html_content = NULL + WHERE id = ?`, + ) + .run(cellId); + void this.notify("layout_cells", "update", cellId); + return; + } + const ent = this.getEntityById(entityId); + if (!ent) return; + this.db + .prepare( + `UPDATE layout_cells + SET entity_id = ?, + content_type = ?, + camera_id = ?, + web_url = ?, + html_content = ? + WHERE id = ?`, + ) + .run( + ent.id, + ent.type, + ent.type === "camera" ? ent.camera_id : null, + ent.type === "web" ? ent.web_url : null, + ent.type === "html" ? ent.html_content : null, + cellId, + ); + void this.notify("layout_cells", "update", cellId); + } + updateLayoutCell(id: number, patch: Partial): void { const sets: string[] = []; const vals: unknown[] = []; @@ -709,6 +774,8 @@ export class Repository { void this.notify("cameras", "create", id); const c = this.getCameraById(id); if (!c) throw new Error("camera vanished after insert"); + // Mirror this camera as a reusable entity so it's pickable in cell editors. + this.ensureCameraEntity(c); return c; } @@ -1135,11 +1202,132 @@ export class Repository { deleteCamera(id: number): void { this.db.prepare(`DELETE FROM camera_labels WHERE camera_id = ?`).run(id); this.db.prepare(`DELETE FROM camera_streams WHERE camera_id = ?`).run(id); + // Clear cells that referenced this camera (legacy column). this.db.prepare(`DELETE FROM layout_cells WHERE camera_id = ?`).run(id); + // entities row has ON DELETE CASCADE → camera-mirror entity goes away with + // the camera, which in turn sets layout_cells.entity_id NULL via the FK. this.db.prepare(`DELETE FROM cameras WHERE id = ?`).run(id); void this.notify("cameras", "delete", id); } + // =========================================================================== + // entities — reusable content pool (camera/html/web) bound to layout cells + // =========================================================================== + + listEntities(): Entity[] { + const rs = this.prep("SELECT * FROM entities ORDER BY name").all(); + return rs.map((r) => rowToEntity(r as Record)); + } + + getEntityById(id: number): Entity | null { + const r = this.prep("SELECT * FROM entities WHERE id = ?").get(id); + return r ? rowToEntity(r as Record) : null; + } + + getEntityByName(name: string): Entity | null { + const r = this.prep("SELECT * FROM entities WHERE name = ?").get(name); + return r ? rowToEntity(r as Record) : null; + } + + getEntityForCamera(cameraId: number): Entity | null { + const r = this.prep( + `SELECT * FROM entities WHERE type = 'camera' AND camera_id = ? LIMIT 1`, + ).get(cameraId); + return r ? rowToEntity(r as Record) : null; + } + + createEntity(input: { + name: string; + type: EntityType; + description?: string | null; + camera_id?: number | null; + html_content?: string | null; + web_url?: string | null; + }): Entity { + const result = this.prep( + `INSERT INTO entities (name, type, description, camera_id, html_content, web_url) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run( + input.name, + input.type, + input.description ?? null, + input.type === "camera" ? (input.camera_id ?? null) : null, + input.type === "html" ? (input.html_content ?? null) : null, + input.type === "web" ? (input.web_url ?? null) : null, + ); + const id = Number(result.lastInsertRowid); + void this.notify("entities", "create", id); + const e = this.getEntityById(id); + if (!e) throw new Error("entity vanished after insert"); + return e; + } + + updateEntity( + id: number, + patch: { + name?: string; + description?: string | null; + camera_id?: number | null; + html_content?: string | null; + web_url?: string | null; + }, + ): void { + const sets: string[] = []; + const vals: unknown[] = []; + for (const [k, v] of Object.entries(patch)) { + sets.push(`${k} = ?`); + vals.push(v === undefined ? null : v); + } + if (sets.length === 0) return; + vals.push(id); + this.db + .prepare(`UPDATE entities SET ${sets.join(", ")} WHERE id = ?`) + .run(...(vals as any[])); + void this.notify("entities", "update", id); + + // Propagate content fields into any cell that uses this entity, so the + // legacy cell columns stay aligned for bundle generation. + const ent = this.getEntityById(id); + if (!ent) return; + this.db + .prepare( + `UPDATE layout_cells + SET content_type = ?, + camera_id = ?, + web_url = ?, + html_content = ? + WHERE entity_id = ?`, + ) + .run( + ent.type, + ent.type === "camera" ? ent.camera_id : null, + ent.type === "web" ? ent.web_url : null, + ent.type === "html" ? ent.html_content : null, + id, + ); + } + + deleteEntity(id: number): void { + // FK ON DELETE SET NULL clears layout_cells.entity_id. + this.db.prepare(`DELETE FROM entities WHERE id = ?`).run(id); + void this.notify("entities", "delete", id); + } + + /** + * Idempotent: ensure a camera-type entity exists for the given camera. If + * the camera's name is already taken by another entity, append the camera + * id to keep the name unique. + */ + ensureCameraEntity(camera: Camera): Entity { + const existing = this.getEntityForCamera(camera.id); + if (existing) return existing; + let name = camera.name; + if (this.getEntityByName(name)) { + name = `${camera.name} (cam #${String(camera.id)})`; + } + return this.createEntity({ name, type: "camera", camera_id: camera.id }); + } + updateKiosk(id: number, patch: Partial): void { const sets: string[] = []; const vals: unknown[] = []; diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index ea51687..1ba6a0a 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -123,18 +123,37 @@ export function generateBundle( preload_camera_ids: l.preload_camera_ids, resets_idle_timer: l.resets_idle_timer, is_default: defaultLayoutId === l.id, - cells: cells.map((c) => ({ - row: c.row, - col: c.col, - row_span: c.row_span, - col_span: c.col_span, - content_type: c.content_type, - camera_id: c.camera_id, - stream_selector: c.stream_selector, - web_url: c.web_url, - html_content: c.html_content, - cooling_timeout_seconds: c.cooling_timeout_seconds, - })), + cells: cells.map((c) => { + // If the cell has an entity, prefer its current content so admin + // edits to the entity propagate without forcing a cell-touch. The + // bundle still ships the legacy camera_id/web_url/html_content shape + // so the existing Rust kiosk consumes it unchanged. + let contentType = c.content_type; + let cameraId = c.camera_id; + let webUrl = c.web_url; + let htmlContent = c.html_content; + if (c.entity_id != null) { + const ent = repo.getEntityById(c.entity_id); + if (ent) { + contentType = ent.type; + cameraId = ent.type === "camera" ? ent.camera_id : null; + webUrl = ent.type === "web" ? ent.web_url : null; + htmlContent = ent.type === "html" ? ent.html_content : null; + } + } + return { + row: c.row, + col: c.col, + row_span: c.row_span, + col_span: c.col_span, + content_type: contentType, + camera_id: cameraId, + stream_selector: c.stream_selector, + web_url: webUrl, + html_content: htmlContent, + cooling_timeout_seconds: c.cooling_timeout_seconds, + }; + }), }; }); diff --git a/server/src/shared/onvif.ts b/server/src/shared/onvif.ts new file mode 100644 index 0000000..57277f9 --- /dev/null +++ b/server/src/shared/onvif.ts @@ -0,0 +1,217 @@ +/** + * Minimal ONVIF discovery client. + * + * Talks SOAP/HTTP directly (no external ONVIF library). Covers the v0.1 + * happy path: GetProfiles + GetStreamUri against the standard media service. + * Uses WS-Security UsernameToken auth (clear-text password digest variant + * skipped — most cameras accept plain text over LAN; we can upgrade later). + * + * Why not the `onvif` npm package? CJS, callback API, no TypeScript types. + * A 50-line SOAP wrapper is easier to maintain than wrapping callbacks. + */ +import { createHash, randomBytes } from "node:crypto"; + +export interface DiscoveredProfile { + profile_name: string; + profile_token: string; + encoding: string | null; + width: number | null; + height: number | null; + framerate: number | null; + stream_uri: string; +} + +interface DiscoverInput { + host: string; + port: number; + username: string; + password: string; + /** Path of the media service endpoint. Most cameras serve at /onvif/device_service for device + /onvif/Media for media. */ + mediaPath?: string; + /** Optional timeout in ms (default 8s). */ + timeoutMs?: number; +} + +function wsseHeader(username: string, password: string): string { + // WS-Security UsernameToken with PasswordDigest (the ONVIF-standard form). + // PasswordDigest = Base64( SHA1( nonce + created + password ) ) + const nonceRaw = randomBytes(16); + const nonce = nonceRaw.toString("base64"); + const created = new Date().toISOString(); + const digest = createHash("sha1") + .update(Buffer.concat([nonceRaw, Buffer.from(created, "utf8"), Buffer.from(password, "utf8")])) + .digest("base64"); + return ` + + + + ${escapeXml(username)} + ${digest} + ${nonce} + ${created} + + + `; +} + +function escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +async function soap(url: string, action: string, body: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": `application/soap+xml; charset=utf-8; action="${action}"`, + "SOAPAction": action, + }, + body, + signal: controller.signal, + }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`ONVIF ${action} HTTP ${String(res.status)}: ${text.slice(0, 300)}`); + } + return text; + } finally { + clearTimeout(t); + } +} + +function buildEnvelope(headerXml: string, bodyXml: string): string { + return ` + + ${headerXml} + ${bodyXml} +`; +} + +// Extract all occurrences of a SOAP element value or attribute via regex. +// XML parsing in regex is regrettable but adequate for ONVIF's small, stable +// schema. Falls back to empty string when not found. +function pickAll(xml: string, tagLocalName: string): string[] { + const re = new RegExp(`<(?:[\\w-]+:)?${tagLocalName}\\b[^>]*>([\\s\\S]*?)`, "g"); + const out: string[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(xml))) { + out.push((m[1] ?? "").trim()); + } + return out; +} + +function pickAttr(xml: string, tagLocalName: string, attr: string): string[] { + const re = new RegExp(`<(?:[\\w-]+:)?${tagLocalName}\\b[^>]*\\b${attr}="([^"]*)"`, "g"); + const out: string[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(xml))) { + out.push(m[1] ?? ""); + } + return out; +} + +// Pull a single nested value from a parent element block. +function pickNested(parentXml: string, tagLocalName: string): string | null { + const m = parentXml.match(new RegExp(`<(?:[\\w-]+:)?${tagLocalName}\\b[^>]*>([\\s\\S]*?)`)); + return m ? (m[1] ?? "").trim() : null; +} + +// Split the response into Profile blocks so we can read per-profile sub-elements. +function splitProfiles(xml: string): string[] { + const re = /<(?:[\w-]+:)?Profiles\b[^>]*>([\s\S]*?)<\/(?:[\w-]+:)?Profiles>/g; + const out: string[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(xml))) { + out.push(m[1] ?? ""); + } + return out; +} + +/** + * Connect to an ONVIF camera and list its media profiles with their + * resolutions, encodings, and RTSP stream URIs. + * + * Throws on transport error. Profile fields default to null if the camera + * omits them. + */ +export async function discover(input: DiscoverInput): Promise { + const host = input.host; + const port = input.port || 80; + const mediaPath = input.mediaPath ?? "/onvif/Media"; + const mediaUrl = `http://${host}:${String(port)}${mediaPath}`; + const timeoutMs = input.timeoutMs ?? 8000; + + const header = wsseHeader(input.username, input.password); + + // ---- GetProfiles ----------------------------------------------------------- + const profilesEnv = buildEnvelope(header, ``); + const profilesXml = await soap( + mediaUrl, + "http://www.onvif.org/ver10/media/wsdl/GetProfiles", + profilesEnv, + timeoutMs, + ); + + const profileBlocks = splitProfiles(profilesXml); + const tokenAttrs = pickAttr(profilesXml, "Profiles", "token"); + + const out: DiscoveredProfile[] = []; + for (let i = 0; i < profileBlocks.length; i += 1) { + const block = profileBlocks[i] ?? ""; + const token = tokenAttrs[i] ?? ""; + const profileName = pickNested(block, "Name") ?? token ?? `profile_${String(i)}`; + + // VideoEncoderConfiguration → Encoding, Resolution{Width,Height}, RateControl.FrameRateLimit + const venc = pickNested(block, "VideoEncoderConfiguration") ?? ""; + const encoding = venc ? pickNested(venc, "Encoding") : null; + const resBlock = venc ? pickNested(venc, "Resolution") : null; + const width = resBlock ? Number(pickNested(resBlock, "Width") ?? "") || null : null; + const height = resBlock ? Number(pickNested(resBlock, "Height") ?? "") || null : null; + const rateCtrl = venc ? pickNested(venc, "RateControl") : null; + const framerate = rateCtrl ? Number(pickNested(rateCtrl, "FrameRateLimit") ?? "") || null : null; + + // ---- GetStreamUri for this profile ------------------------------------- + const streamBody = ` + + RTP-Unicast + RTSP + + ${escapeXml(token)} + `; + const streamEnv = buildEnvelope(wsseHeader(input.username, input.password), streamBody); + let streamXml: string; + try { + streamXml = await soap( + mediaUrl, + "http://www.onvif.org/ver10/media/wsdl/GetStreamUri", + streamEnv, + timeoutMs, + ); + } catch { + continue; // skip profiles we can't get a stream uri for + } + const uri = pickAll(streamXml, "Uri")[0] ?? ""; + if (!uri) continue; + + out.push({ + profile_name: profileName, + profile_token: token, + encoding, + width, + height, + framerate, + stream_uri: uri, + }); + } + + return out; +} diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index c40b4f3..b616dbc 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -13,6 +13,18 @@ export type StreamSelector = "auto" | "main" | "sub"; export type StreamPolicy = "auto" | "always_main" | "always_sub"; export type LayoutPriority = "hot" | "normal" | "cold"; export type CellContentType = "camera" | "web" | "html"; +export type EntityType = "camera" | "html" | "web"; + +export interface Entity { + id: number; + name: string; + type: EntityType; + description: string | null; + camera_id: number | null; + html_content: string | null; + web_url: string | null; + created_at: string; +} export type DesiredPowerState = "follow_layout" | "on" | "standby"; export type LabelRole = "consume" | "operate"; export type EventSourceType = "onvif" | "gpio" | "synthetic" | "system"; @@ -174,6 +186,7 @@ export interface LayoutCell { html_content: string | null; cooling_timeout_seconds: number | null; options: Record; + entity_id: number | null; } export interface Kiosk { diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index e4bd48b..05b3920 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -6,6 +6,7 @@ import { Layout } from "./layout.js"; import type { Camera, Display, + Entity, Kiosk, Label, Layout as LayoutType, @@ -168,7 +169,11 @@ export function CameraNewPage(props: CameraNewProps) { flash={props.error ? { type: "error", message: props.error } : undefined} >
+
+
- -
- - + + +
+
+
+ + +
+
+ +
- -
+
- - -
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
- - @@ -248,18 +216,361 @@ export function CameraNewPage(props: CameraNewProps) { Cancel
+ + ); +} - +// ---- Camera ONVIF Discovery ------------------------------------------------ + +interface CameraDiscoverProps { + user: string; + error?: string; + values?: Record; +} + +export function CameraDiscoverPage(props: CameraDiscoverProps) { + const v = props.values ?? {}; + return ( + +
+

+ Connect to an ONVIF camera or NVR by host and credentials. Each profile + returned can be saved as a separate RTSP camera. +

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + Cancel +
+
+
+ ); +} + +interface DiscoveredProfileRow { + profile_name: string; + profile_token: string; + encoding: string | null; + width: number | null; + height: number | null; + framerate: number | null; + stream_uri: string; +} + +interface CameraDiscoverResultsProps { + user: string; + host: string; + profiles: DiscoveredProfileRow[]; + error?: string; + success?: string; +} + +export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) { + return ( + +

+ Profiles reported by {props.host}. Click Add on + any row to import it as a camera. +

+
+ + + + + + + + + + + + + {props.profiles.length === 0 ? ( + + ) : ( + props.profiles.map((p) => ( + + + + + + + + + )) + )} + +
ProfileEncodingResolutionFPSStream URI
No profiles returned
{p.profile_name}{p.encoding ? {p.encoding} : "—"}{p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "—"}{p.framerate != null ? String(p.framerate) : "—"}{p.stream_uri} +
+ + + + + + + + +
+
+
+ +
+ ); +} + +// ---- Entities --------------------------------------------------------------- + +interface EntitiesPageProps { + user: string; + entities: Entity[]; +} + +function entityBadge(type: string) { + const cls = type === "camera" ? "badge-blue" : type === "web" ? "badge-green" : "badge-gray"; + return {type}; +} + +function entityDetail(e: Entity): string { + if (e.type === "camera") return e.camera_id ? `cam #${String(e.camera_id)}` : "—"; + if (e.type === "web") return e.web_url ?? "—"; + if (e.type === "html") return e.html_content ? `${e.html_content.slice(0, 80)}…` : "—"; + return "—"; +} + +export function EntitiesPage(props: EntitiesPageProps) { + return ( + +
+

All Entities

+ New Entity +
+

+ Entities are reusable content blocks (a camera reference, an HTML + snippet, or a web page). Bind one entity to any number of layout cells — + edit the entity once and every cell updates. +

+
+ + + + + + + + + + {props.entities.length === 0 ? ( + + ) : ( + props.entities.map((e) => ( + + + + + + )) + )} + +
NameTypeDetail
No entities yet
{e.name}{entityBadge(e.type)}{entityDetail(e)}
+
+
+ ); +} + +interface EntityNewPageProps { + user: string; + cameras: Camera[]; + error?: string; + values?: Record; +} + +function entityFormScript(rootId: string): string { + // Show/hide type-specific fieldsets when the type select changes. + return ( + `(function(){` + + `var root=document.getElementById('${rootId}');` + + `if(!root)return;` + + `var sel=root.querySelector('select[name="type"]');` + + `var cf=root.querySelector('.ent-fields-camera');` + + `var wf=root.querySelector('.ent-fields-web');` + + `var hf=root.querySelector('.ent-fields-html');` + + `function t(){var v=sel?sel.value:"html";` + + `if(cf)cf.style.display=v==='camera'?'block':'none';` + + `if(wf)wf.style.display=v==='web'?'block':'none';` + + `if(hf)hf.style.display=v==='html'?'block':'none';}` + + `if(sel)sel.addEventListener('change',t);t();})()` + ); +} + +export function EntityNewPage(props: EntityNewPageProps) { + const v = props.values ?? {}; + const selType = v["type"] ?? "html"; + return ( + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + + Cancel +
+
+ +
+ ); +} + +interface EntityEditPageProps { + user: string; + entity: Entity; + cameras: Camera[]; + error?: string; + success?: string; +} + +export function EntityEditPage(props: EntityEditPageProps) { + const e = props.entity; + return ( + +
+
+
Type: {entityBadge(e.type)}
+
+ {/* Type is fixed after creation — switching type would break attached cells. */} + +
+ + +
+
+ + +
+ + {e.type === "camera" && ( +
+ + +
+ )} + {e.type === "web" && ( +
+ + +
+ )} + {e.type === "html" && ( +
+ + +
+ )} + + + Back +
+
+ +
+ +
+
); } @@ -1013,6 +1324,7 @@ interface LayoutEditPageProps { displays: Display[]; cells: LayoutCell[]; cameras: Camera[]; + entities: Entity[]; /** If set, render the content-assignment form for this cell beneath the grid. */ selectedCellId?: number | null; error?: string; @@ -1050,7 +1362,15 @@ export const LAYOUT_BUILDER_CSS = ` .layout-empty-add:hover { background: #1e40af; } `; -function cellLabel(c: LayoutCell, cameraById: Map): string { +function cellLabel( + c: LayoutCell, + entityById: Map, + cameraById: Map, +): string { + if (c.entity_id != null) { + const ent = entityById.get(c.entity_id); + if (ent) return ent.name; + } if (c.content_type === "camera" && c.camera_id) { return cameraById.get(c.camera_id)?.name ?? `cam #${String(c.camera_id)}`; } @@ -1071,11 +1391,14 @@ function cellGridStyle(c: LayoutCell): string { export function renderCell( layoutId: number, c: LayoutCell, + entities: Entity[], cameras: Camera[], mode: "read" | "edit", ): string { const cameraById = new Map(); for (const cam of cameras) cameraById.set(cam.id, cam); + const entityById = new Map(); + for (const e of entities) entityById.set(e.id, e); const style = cellGridStyle(c); const cellGetUrl = `/admin/layouts/${String(layoutId)}/cells/${String(c.id)}`; const cellEditUrl = `${cellGetUrl}/edit`; @@ -1093,46 +1416,27 @@ export function renderCell( hx-swap="outerHTML" >
- -
- - - + + +
-
-
- - -
-
- - -
-
- -
-
- - -
-
- -
-
- - -
+
+ +
@@ -1165,30 +1469,18 @@ export function renderCell( >Delete
-
); } - // Read mode. - const isEmpty = (c.content_type === "html" && !c.html_content) + // Read mode. Empty when no entity is bound. + const ent = c.entity_id != null ? entityById.get(c.entity_id) ?? null : null; + const isEmpty = !ent && ( + (c.content_type === "html" && !c.html_content) || (c.content_type === "camera" && !c.camera_id) - || (c.content_type === "web" && !c.web_url); - const label = cellLabel(c, cameraById); + || (c.content_type === "web" && !c.web_url) + ); + const label = cellLabel(c, entityById, cameraById); return (
- {cells.map((c) => renderCell(layoutId, c, cameras, "read"))} + {cells.map((c) => renderCell(layoutId, c, entities, cameras, "read"))}
); } @@ -1408,7 +1701,7 @@ export function LayoutEditPage(props: LayoutEditPageProps) { Click a cell to edit content in-place.

- {renderGrid(l.id, cells, props.cameras)} + {renderGrid(l.id, cells, props.entities, props.cameras)}
diff --git a/server/src/web-templates/layout.tsx b/server/src/web-templates/layout.tsx index cf625f7..8c9e95c 100644 --- a/server/src/web-templates/layout.tsx +++ b/server/src/web-templates/layout.tsx @@ -43,6 +43,7 @@ function Sidebar(props: { activeNav?: string }) {