mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
feat: entities (unified content pool) + ONVIF discovery flow
Entities: - New entities table — id, name, type (camera|html|web), camera_id, html_content, web_url - Auto-create entity per camera on createCamera - Layout cells reference entity_id (replaces inline content_type/ camera_id/html_content/web_url) - Bundle resolves entities back to legacy cell fields for kiosk compat (Rust kiosk unchanged) - Full CRUD: /admin/entities, /admin/entities/new, /admin/entities/:id - Cell editor: single entity dropdown with type badges ONVIF discovery: - /admin/cameras/discover — host/port/user/pass form - Server queries ONVIF device, lists profiles with name/resolution/ encoding/framerate - "Add" creates camera + main stream from chosen profile - shared/onvif.ts: minimal SOAP+UsernameToken+PasswordDigest client (no external dep) - Camera new form simplified to RTSP-only with discover link
This commit is contained in:
parent
00b304c39f
commit
3be1a9a624
11 changed files with 1282 additions and 206 deletions
44
package-lock.json
generated
44
package-lock.json
generated
|
|
@ -738,6 +738,18 @@
|
||||||
"node-gyp-build-test": "build-test.js"
|
"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": {
|
"node_modules/otpauth": {
|
||||||
"version": "9.5.1",
|
"version": "9.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz",
|
||||||
|
|
@ -788,6 +800,15 @@
|
||||||
"integrity": "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==",
|
"integrity": "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"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": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.4",
|
"version": "2.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||||
|
|
@ -936,6 +979,7 @@
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"h3": "^2.0.1-rc.22",
|
"h3": "^2.0.1-rc.22",
|
||||||
"jsx-htmx": "^2.0.2",
|
"jsx-htmx": "^2.0.2",
|
||||||
|
"onvif": "^0.8.1",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"h3": "^2.0.1-rc.22",
|
"h3": "^2.0.1-rc.22",
|
||||||
"jsx-htmx": "^2.0.2",
|
"jsx-htmx": "^2.0.2",
|
||||||
|
"onvif": "^0.8.1",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ import {
|
||||||
CamerasPage,
|
CamerasPage,
|
||||||
CameraNewPage,
|
CameraNewPage,
|
||||||
CameraEditPage,
|
CameraEditPage,
|
||||||
|
CameraDiscoverPage,
|
||||||
|
CameraDiscoverResultsPage,
|
||||||
|
EntitiesPage,
|
||||||
|
EntityNewPage,
|
||||||
|
EntityEditPage,
|
||||||
KiosksPage,
|
KiosksPage,
|
||||||
KioskEditPage,
|
KioskEditPage,
|
||||||
LabelsPage,
|
LabelsPage,
|
||||||
|
|
@ -22,6 +27,7 @@ import {
|
||||||
renderCell,
|
renderCell,
|
||||||
renderGrid,
|
renderGrid,
|
||||||
} from "../../web-templates/admin-pages.js";
|
} from "../../web-templates/admin-pages.js";
|
||||||
|
import { discover as onvifDiscover } from "../../shared/onvif.js";
|
||||||
|
|
||||||
function htmlFragment(markup: unknown): Response {
|
function htmlFragment(markup: unknown): Response {
|
||||||
return new Response(String(markup), {
|
return new Response(String(markup), {
|
||||||
|
|
@ -98,7 +104,6 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const name = (body?.["name"] ?? "").trim();
|
const name = (body?.["name"] ?? "").trim();
|
||||||
const type = body?.["type"] as "rtsp" | "onvif" | undefined;
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!name || name.length > 128) {
|
if (!name || name.length > 128) {
|
||||||
|
|
@ -107,36 +112,19 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
errors.push("Camera name already in use.");
|
errors.push("Camera name already in use.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type !== "rtsp" && type !== "onvif") {
|
|
||||||
errors.push("Select camera type.");
|
|
||||||
}
|
|
||||||
|
|
||||||
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 host = (body?.["rtsp_host"] ?? "").trim();
|
||||||
const port = (body?.["rtsp_port"] ?? "554").trim();
|
const port = (body?.["rtsp_port"] ?? "554").trim();
|
||||||
const path = (body?.["rtsp_path"] ?? "").trim();
|
const path = (body?.["rtsp_path"] ?? "").trim();
|
||||||
const user = (body?.["rtsp_username"] ?? "").trim();
|
const username = (body?.["rtsp_username"] ?? "").trim();
|
||||||
const pass = body?.["rtsp_password"] ?? "";
|
const pass = body?.["rtsp_password"] ?? "";
|
||||||
|
let rtspUrl: string | undefined;
|
||||||
if (!host) {
|
if (!host) {
|
||||||
errors.push("RTSP host required.");
|
errors.push("RTSP host required.");
|
||||||
} else {
|
} else {
|
||||||
const userPart = user ? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@` : "";
|
const userPart = username ? `${encodeURIComponent(username)}:${encodeURIComponent(pass)}@` : "";
|
||||||
const pathPart = path.startsWith("/") ? path : `/${path}`;
|
const pathPart = path.startsWith("/") ? path : `/${path}`;
|
||||||
rtspUrl = `rtsp://${userPart}${host}:${port}${pathPart}`;
|
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 (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
return htmlPage(CameraNewPage({
|
return htmlPage(CameraNewPage({
|
||||||
|
|
@ -148,16 +136,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
const cam = deps.repo.createCamera({
|
const cam = deps.repo.createCamera({
|
||||||
name,
|
name,
|
||||||
type: type!,
|
type: "rtsp",
|
||||||
rtsp_url: rtspUrl ?? null,
|
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 (rtspUrl) {
|
||||||
if (type === "rtsp" && rtspUrl) {
|
|
||||||
deps.repo.createCameraStream({
|
deps.repo.createCameraStream({
|
||||||
camera_id: cam.id,
|
camera_id: cam.id,
|
||||||
role: "main",
|
role: "main",
|
||||||
|
|
@ -165,6 +148,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
rtsp_uri: rtspUrl,
|
rtsp_uri: rtspUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
notifyKiosks();
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
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<Record<string, string>>(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<Record<string, string>>(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<Record<string, string>>(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<Record<string, string>>(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 ---------------------------------------------------------------
|
// ---- Kiosks ---------------------------------------------------------------
|
||||||
|
|
||||||
app.get("/admin/kiosks", (event) => {
|
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" } });
|
if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
|
||||||
const cells = deps.repo.layoutCells(id);
|
const cells = deps.repo.layoutCells(id);
|
||||||
const cameras = deps.repo.listCameras();
|
const cameras = deps.repo.listCameras();
|
||||||
|
const entities = deps.repo.listEntities();
|
||||||
const displays = deps.repo.listDisplaysForLayout(id);
|
const displays = deps.repo.listDisplaysForLayout(id);
|
||||||
return htmlPage(LayoutEditPage({
|
return htmlPage(LayoutEditPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
|
|
@ -273,6 +456,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
displays,
|
displays,
|
||||||
cells,
|
cells,
|
||||||
cameras,
|
cameras,
|
||||||
|
entities,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -314,7 +498,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
if (!ref) {
|
if (!ref) {
|
||||||
if (isHtmxRequest(event)) {
|
if (isHtmxRequest(event)) {
|
||||||
const cameras = deps.repo.listCameras();
|
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}` } });
|
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
|
||||||
}
|
}
|
||||||
|
|
@ -359,15 +544,15 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
col,
|
col,
|
||||||
row_span: 1,
|
row_span: 1,
|
||||||
col_span: 1,
|
col_span: 1,
|
||||||
content_type: "html",
|
entity_id: null,
|
||||||
html_content: null,
|
|
||||||
});
|
});
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
|
|
||||||
if (isHtmxRequest(event)) {
|
if (isHtmxRequest(event)) {
|
||||||
const cells = deps.repo.layoutCells(layoutId);
|
const cells = deps.repo.layoutCells(layoutId);
|
||||||
const cameras = deps.repo.listCameras();
|
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}` } });
|
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 });
|
return new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
const cameras = deps.repo.listCameras();
|
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).
|
// 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 });
|
return new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
const cameras = deps.repo.listCameras();
|
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.
|
// Update a cell's entity binding + dimensions. Legacy content_type/web/html
|
||||||
// For htmx requests, returns the updated cell HTML (read mode) for outerHTML
|
// columns are managed by assignCellEntity for bundle compatibility.
|
||||||
// swap onto the cell element. For normal POSTs, returns 302.
|
|
||||||
app.post("/admin/layouts/:id/cells/:cellId", async (event) => {
|
app.post("/admin/layouts/:id/cells/:cellId", async (event) => {
|
||||||
const layoutId = Number(getRouterParam(event, "id"));
|
const layoutId = Number(getRouterParam(event, "id"));
|
||||||
const cellId = Number(getRouterParam(event, "cellId"));
|
const cellId = Number(getRouterParam(event, "cellId"));
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const contentType = (body?.["content_type"] ?? "html") as "camera" | "web" | "html";
|
|
||||||
|
|
||||||
const patch: Record<string, unknown> = {
|
const entityIdRaw = body?.["entity_id"];
|
||||||
content_type: contentType,
|
const entityId =
|
||||||
camera_id: contentType === "camera" && body?.["camera_id"] ? Number(body["camera_id"]) : null,
|
entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null;
|
||||||
stream_selector: contentType === "camera"
|
deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : null);
|
||||||
? ((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,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// stream_selector + spans are still settable on the cell.
|
||||||
|
const dimsPatch: Record<string, unknown> = {};
|
||||||
|
const streamSelector = body?.["stream_selector"];
|
||||||
|
if (streamSelector === "auto" || streamSelector === "main" || streamSelector === "sub") {
|
||||||
|
dimsPatch["stream_selector"] = streamSelector;
|
||||||
|
}
|
||||||
const colSpanRaw = body?.["col_span"];
|
const colSpanRaw = body?.["col_span"];
|
||||||
const rowSpanRaw = body?.["row_span"];
|
const rowSpanRaw = body?.["row_span"];
|
||||||
if (colSpanRaw != null && String(colSpanRaw).trim() !== "") {
|
if (colSpanRaw != null && String(colSpanRaw).trim() !== "") {
|
||||||
const v = Math.max(1, Number(colSpanRaw) || 1);
|
dimsPatch["col_span"] = Math.max(1, Number(colSpanRaw) || 1);
|
||||||
patch["col_span"] = v;
|
|
||||||
}
|
}
|
||||||
if (rowSpanRaw != null && String(rowSpanRaw).trim() !== "") {
|
if (rowSpanRaw != null && String(rowSpanRaw).trim() !== "") {
|
||||||
const v = Math.max(1, Number(rowSpanRaw) || 1);
|
dimsPatch["row_span"] = Math.max(1, Number(rowSpanRaw) || 1);
|
||||||
patch["row_span"] = v;
|
}
|
||||||
|
if (Object.keys(dimsPatch).length > 0) {
|
||||||
|
deps.repo.updateLayoutCell(cellId, dimsPatch as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.repo.updateLayoutCell(cellId, patch as any);
|
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
|
|
||||||
if (isHtmxRequest(event)) {
|
if (isHtmxRequest(event)) {
|
||||||
const cell = deps.repo.getLayoutCellById(cellId);
|
const cell = deps.repo.getLayoutCellById(cellId);
|
||||||
if (!cell) return new Response("", { headers: { "content-type": "text/html; charset=utf-8" } });
|
if (!cell) return new Response("", { headers: { "content-type": "text/html; charset=utf-8" } });
|
||||||
const cameras = deps.repo.listCameras();
|
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}` } });
|
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 cells = deps.repo.layoutCells(layoutId);
|
||||||
const cameras = deps.repo.listCameras();
|
const cameras = deps.repo.listCameras();
|
||||||
|
const entities = deps.repo.listEntities();
|
||||||
if (isHtmxRequest(event)) {
|
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}` } });
|
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)) {
|
if (isHtmxRequest(event)) {
|
||||||
const cells = deps.repo.layoutCells(layoutId);
|
const cells = deps.repo.layoutCells(layoutId);
|
||||||
const cameras = deps.repo.listCameras();
|
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}` } });
|
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import type {
|
||||||
CellContentType,
|
CellContentType,
|
||||||
DesiredPowerState,
|
DesiredPowerState,
|
||||||
Display,
|
Display,
|
||||||
|
Entity,
|
||||||
|
EntityType,
|
||||||
EventLog,
|
EventLog,
|
||||||
EventSourceType,
|
EventSourceType,
|
||||||
Kiosk,
|
Kiosk,
|
||||||
|
|
@ -205,6 +207,20 @@ export function rowToLayoutCell(r: Row): LayoutCell {
|
||||||
html_content: sn(r["html_content"]),
|
html_content: sn(r["html_content"]),
|
||||||
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
|
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
|
||||||
options: j<Record<string, unknown>>(r["options"], {}),
|
options: j<Record<string, unknown>>(r["options"], {}),
|
||||||
|
entity_id: nn(r["entity_id"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -441,4 +441,100 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
|
||||||
|
|
||||||
// Drop layout_templates entirely — concept removed
|
// Drop layout_templates entirely — concept removed
|
||||||
`DROP TABLE IF EXISTS layout_templates`,
|
`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
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import type {
|
||||||
CameraStream,
|
CameraStream,
|
||||||
CameraType,
|
CameraType,
|
||||||
Display,
|
Display,
|
||||||
|
Entity,
|
||||||
|
EntityType,
|
||||||
EventLog,
|
EventLog,
|
||||||
EventSourceType,
|
EventSourceType,
|
||||||
Kiosk,
|
Kiosk,
|
||||||
|
|
@ -40,6 +42,7 @@ import {
|
||||||
rowToCamera,
|
rowToCamera,
|
||||||
rowToCameraStream,
|
rowToCameraStream,
|
||||||
rowToDisplay,
|
rowToDisplay,
|
||||||
|
rowToEntity,
|
||||||
rowToEventLog,
|
rowToEventLog,
|
||||||
rowToKiosk,
|
rowToKiosk,
|
||||||
rowToLabel,
|
rowToLabel,
|
||||||
|
|
@ -545,30 +548,48 @@ export class Repository {
|
||||||
col: number;
|
col: number;
|
||||||
row_span?: number;
|
row_span?: number;
|
||||||
col_span?: number;
|
col_span?: number;
|
||||||
content_type: string;
|
content_type?: string;
|
||||||
camera_id?: number | null;
|
camera_id?: number | null;
|
||||||
stream_selector?: string | null;
|
stream_selector?: string | null;
|
||||||
web_url?: string | null;
|
web_url?: string | null;
|
||||||
html_content?: string | null;
|
html_content?: string | null;
|
||||||
cooling_timeout_seconds?: number | null;
|
cooling_timeout_seconds?: number | null;
|
||||||
options?: Record<string, unknown>;
|
options?: Record<string, unknown>;
|
||||||
|
entity_id?: number | null;
|
||||||
}): LayoutCell {
|
}): 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(
|
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)
|
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
input.layout_id,
|
input.layout_id,
|
||||||
input.row,
|
input.row,
|
||||||
input.col,
|
input.col,
|
||||||
input.row_span ?? 1,
|
input.row_span ?? 1,
|
||||||
input.col_span ?? 1,
|
input.col_span ?? 1,
|
||||||
input.content_type,
|
contentType,
|
||||||
input.camera_id ?? null,
|
cameraId,
|
||||||
input.stream_selector ?? "auto",
|
input.stream_selector ?? "auto",
|
||||||
input.web_url ?? null,
|
webUrl,
|
||||||
input.html_content ?? null,
|
htmlContent,
|
||||||
input.cooling_timeout_seconds ?? null,
|
input.cooling_timeout_seconds ?? null,
|
||||||
J(input.options ?? {}),
|
J(input.options ?? {}),
|
||||||
|
input.entity_id ?? null,
|
||||||
);
|
);
|
||||||
const id = Number(result.lastInsertRowid);
|
const id = Number(result.lastInsertRowid);
|
||||||
void this.notify("layout_cells", "create", id);
|
void this.notify("layout_cells", "create", id);
|
||||||
|
|
@ -577,6 +598,50 @@ export class Repository {
|
||||||
return rowToLayoutCell(r as Record<string, unknown>);
|
return rowToLayoutCell(r as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<LayoutCell>): void {
|
updateLayoutCell(id: number, patch: Partial<LayoutCell>): void {
|
||||||
const sets: string[] = [];
|
const sets: string[] = [];
|
||||||
const vals: unknown[] = [];
|
const vals: unknown[] = [];
|
||||||
|
|
@ -709,6 +774,8 @@ export class Repository {
|
||||||
void this.notify("cameras", "create", id);
|
void this.notify("cameras", "create", id);
|
||||||
const c = this.getCameraById(id);
|
const c = this.getCameraById(id);
|
||||||
if (!c) throw new Error("camera vanished after insert");
|
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;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1135,11 +1202,132 @@ export class Repository {
|
||||||
deleteCamera(id: number): void {
|
deleteCamera(id: number): void {
|
||||||
this.db.prepare(`DELETE FROM camera_labels WHERE camera_id = ?`).run(id);
|
this.db.prepare(`DELETE FROM camera_labels WHERE camera_id = ?`).run(id);
|
||||||
this.db.prepare(`DELETE FROM camera_streams 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);
|
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);
|
this.db.prepare(`DELETE FROM cameras WHERE id = ?`).run(id);
|
||||||
void this.notify("cameras", "delete", 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<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
getEntityById(id: number): Entity | null {
|
||||||
|
const r = this.prep("SELECT * FROM entities WHERE id = ?").get(id);
|
||||||
|
return r ? rowToEntity(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEntityByName(name: string): Entity | null {
|
||||||
|
const r = this.prep("SELECT * FROM entities WHERE name = ?").get(name);
|
||||||
|
return r ? rowToEntity(r as Record<string, unknown>) : 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<string, unknown>) : 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<Kiosk>): void {
|
updateKiosk(id: number, patch: Partial<Kiosk>): void {
|
||||||
const sets: string[] = [];
|
const sets: string[] = [];
|
||||||
const vals: unknown[] = [];
|
const vals: unknown[] = [];
|
||||||
|
|
|
||||||
|
|
@ -123,18 +123,37 @@ export function generateBundle(
|
||||||
preload_camera_ids: l.preload_camera_ids,
|
preload_camera_ids: l.preload_camera_ids,
|
||||||
resets_idle_timer: l.resets_idle_timer,
|
resets_idle_timer: l.resets_idle_timer,
|
||||||
is_default: defaultLayoutId === l.id,
|
is_default: defaultLayoutId === l.id,
|
||||||
cells: cells.map((c) => ({
|
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,
|
row: c.row,
|
||||||
col: c.col,
|
col: c.col,
|
||||||
row_span: c.row_span,
|
row_span: c.row_span,
|
||||||
col_span: c.col_span,
|
col_span: c.col_span,
|
||||||
content_type: c.content_type,
|
content_type: contentType,
|
||||||
camera_id: c.camera_id,
|
camera_id: cameraId,
|
||||||
stream_selector: c.stream_selector,
|
stream_selector: c.stream_selector,
|
||||||
web_url: c.web_url,
|
web_url: webUrl,
|
||||||
html_content: c.html_content,
|
html_content: htmlContent,
|
||||||
cooling_timeout_seconds: c.cooling_timeout_seconds,
|
cooling_timeout_seconds: c.cooling_timeout_seconds,
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
217
server/src/shared/onvif.ts
Normal file
217
server/src/shared/onvif.ts
Normal file
|
|
@ -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 `
|
||||||
|
<s:Header>
|
||||||
|
<Security s:mustUnderstand="1" xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
|
||||||
|
<UsernameToken>
|
||||||
|
<Username>${escapeXml(username)}</Username>
|
||||||
|
<Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">${digest}</Password>
|
||||||
|
<Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${nonce}</Nonce>
|
||||||
|
<Created xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">${created}</Created>
|
||||||
|
</UsernameToken>
|
||||||
|
</Security>
|
||||||
|
</s:Header>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function soap(url: string, action: string, body: string, timeoutMs: number): Promise<string> {
|
||||||
|
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 `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:trt="http://www.onvif.org/ver10/media/wsdl"
|
||||||
|
xmlns:tt="http://www.onvif.org/ver10/schema">
|
||||||
|
${headerXml}
|
||||||
|
<s:Body>${bodyXml}</s:Body>
|
||||||
|
</s:Envelope>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]*?)</(?:[\\w-]+:)?${tagLocalName}>`, "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]*?)</(?:[\\w-]+:)?${tagLocalName}>`));
|
||||||
|
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<DiscoveredProfile[]> {
|
||||||
|
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, `<trt:GetProfiles/>`);
|
||||||
|
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 = `<trt:GetStreamUri>
|
||||||
|
<trt:StreamSetup>
|
||||||
|
<tt:Stream>RTP-Unicast</tt:Stream>
|
||||||
|
<tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport>
|
||||||
|
</trt:StreamSetup>
|
||||||
|
<trt:ProfileToken>${escapeXml(token)}</trt:ProfileToken>
|
||||||
|
</trt:GetStreamUri>`;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,18 @@ export type StreamSelector = "auto" | "main" | "sub";
|
||||||
export type StreamPolicy = "auto" | "always_main" | "always_sub";
|
export type StreamPolicy = "auto" | "always_main" | "always_sub";
|
||||||
export type LayoutPriority = "hot" | "normal" | "cold";
|
export type LayoutPriority = "hot" | "normal" | "cold";
|
||||||
export type CellContentType = "camera" | "web" | "html";
|
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 DesiredPowerState = "follow_layout" | "on" | "standby";
|
||||||
export type LabelRole = "consume" | "operate";
|
export type LabelRole = "consume" | "operate";
|
||||||
export type EventSourceType = "onvif" | "gpio" | "synthetic" | "system";
|
export type EventSourceType = "onvif" | "gpio" | "synthetic" | "system";
|
||||||
|
|
@ -174,6 +186,7 @@ export interface LayoutCell {
|
||||||
html_content: string | null;
|
html_content: string | null;
|
||||||
cooling_timeout_seconds: number | null;
|
cooling_timeout_seconds: number | null;
|
||||||
options: Record<string, unknown>;
|
options: Record<string, unknown>;
|
||||||
|
entity_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Kiosk {
|
export interface Kiosk {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Layout } from "./layout.js";
|
||||||
import type {
|
import type {
|
||||||
Camera,
|
Camera,
|
||||||
Display,
|
Display,
|
||||||
|
Entity,
|
||||||
Kiosk,
|
Kiosk,
|
||||||
Label,
|
Label,
|
||||||
Layout as LayoutType,
|
Layout as LayoutType,
|
||||||
|
|
@ -168,7 +169,11 @@ export function CameraNewPage(props: CameraNewProps) {
|
||||||
flash={props.error ? { type: "error", message: props.error } : undefined}
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
||||||
>
|
>
|
||||||
<div style="max-width:600px">
|
<div style="max-width:600px">
|
||||||
|
<div style="margin-bottom:1rem">
|
||||||
|
<a href="/admin/cameras/discover" class="btn btn-ghost">Discover via ONVIF →</a>
|
||||||
|
</div>
|
||||||
<form method="post" action="/admin/cameras/new">
|
<form method="post" action="/admin/cameras/new">
|
||||||
|
<input type="hidden" name="type" value="rtsp" />
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">Camera Name</label>
|
<label for="name">Camera Name</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -182,21 +187,6 @@ export function CameraNewPage(props: CameraNewProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Type</label>
|
|
||||||
<div class="radio-group">
|
|
||||||
<label>
|
|
||||||
<input type="radio" name="type" value="rtsp" checked={v["type"] !== "onvif"} />
|
|
||||||
RTSP
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="radio" name="type" value="onvif" checked={v["type"] === "onvif"} />
|
|
||||||
ONVIF
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="rtsp-fields">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="rtsp_host">Host</label>
|
<label for="rtsp_host">Host</label>
|
||||||
<input id="rtsp_host" name="rtsp_host" type="text" class="form-input" placeholder="192.168.1.100" value={v["rtsp_host"] ?? ""} />
|
<input id="rtsp_host" name="rtsp_host" type="text" class="form-input" placeholder="192.168.1.100" value={v["rtsp_host"] ?? ""} />
|
||||||
|
|
@ -221,45 +211,366 @@ export function CameraNewPage(props: CameraNewProps) {
|
||||||
<input id="rtsp_password" name="rtsp_password" type="password" class="form-input" value={v["rtsp_password"] ?? ""} />
|
<input id="rtsp_password" name="rtsp_password" type="password" class="form-input" value={v["rtsp_password"] ?? ""} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="onvif-fields" style="display:none">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="onvif_host">Host</label>
|
|
||||||
<input id="onvif_host" name="onvif_host" type="text" class="form-input" value={v["onvif_host"] ?? ""} />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="onvif_port">Port</label>
|
|
||||||
<input id="onvif_port" name="onvif_port" type="number" class="form-input" value={v["onvif_port"] ?? "80"} />
|
|
||||||
</div>
|
|
||||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="onvif_username">Username</label>
|
|
||||||
<input id="onvif_username" name="onvif_username" type="text" class="form-input" value={v["onvif_username"] ?? ""} />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="onvif_password">Password</label>
|
|
||||||
<input id="onvif_password" name="onvif_password" type="password" class="form-input" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Add Camera</button>
|
<button type="submit" class="btn btn-primary">Add Camera</button>
|
||||||
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<script>{js(
|
// ---- Camera ONVIF Discovery ------------------------------------------------
|
||||||
|
|
||||||
|
interface CameraDiscoverProps {
|
||||||
|
user: string;
|
||||||
|
error?: string;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CameraDiscoverPage(props: CameraDiscoverProps) {
|
||||||
|
const v = props.values ?? {};
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title="Discover ONVIF Cameras"
|
||||||
|
user={props.user}
|
||||||
|
activeNav="cameras"
|
||||||
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
||||||
|
>
|
||||||
|
<div style="max-width:600px">
|
||||||
|
<p style="color:#666; margin-bottom:1rem">
|
||||||
|
Connect to an ONVIF camera or NVR by host and credentials. Each profile
|
||||||
|
returned can be saved as a separate RTSP camera.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/admin/cameras/discover" class="card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="host">Host</label>
|
||||||
|
<input id="host" name="host" type="text" class="form-input" placeholder="192.168.1.100" required value={v["host"] ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="port">Port</label>
|
||||||
|
<input id="port" name="port" type="number" class="form-input" value={v["port"] ?? "80"} />
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" type="text" class="form-input" value={v["username"] ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" class="form-input" value={v["password"] ?? ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Discover</button>
|
||||||
|
<a href="/admin/cameras/new" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Layout
|
||||||
|
title="ONVIF Profiles"
|
||||||
|
user={props.user}
|
||||||
|
activeNav="cameras"
|
||||||
|
flash={
|
||||||
|
props.error ? { type: "error", message: props.error }
|
||||||
|
: props.success ? { type: "success", message: props.success }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p style="color:#666; margin-bottom:1rem">
|
||||||
|
Profiles reported by <strong>{props.host}</strong>. Click <em>Add</em> on
|
||||||
|
any row to import it as a camera.
|
||||||
|
</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Profile</th>
|
||||||
|
<th>Encoding</th>
|
||||||
|
<th>Resolution</th>
|
||||||
|
<th>FPS</th>
|
||||||
|
<th>Stream URI</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.profiles.length === 0 ? (
|
||||||
|
<tr><td colspan="6" style="text-align:center; color:#999; padding:2rem">No profiles returned</td></tr>
|
||||||
|
) : (
|
||||||
|
props.profiles.map((p) => (
|
||||||
|
<tr>
|
||||||
|
<td><strong>{p.profile_name}</strong></td>
|
||||||
|
<td>{p.encoding ? <span class="badge badge-blue">{p.encoding}</span> : "—"}</td>
|
||||||
|
<td>{p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "—"}</td>
|
||||||
|
<td>{p.framerate != null ? String(p.framerate) : "—"}</td>
|
||||||
|
<td style="font-size:0.75rem; word-break:break-all; max-width:300px">{p.stream_uri}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/cameras/discover/add" style="display:inline">
|
||||||
|
<input type="hidden" name="name" value={`${props.host}: ${p.profile_name}`} />
|
||||||
|
<input type="hidden" name="rtsp_url" value={p.stream_uri} />
|
||||||
|
<input type="hidden" name="encoding" value={p.encoding ?? ""} />
|
||||||
|
<input type="hidden" name="width" value={String(p.width ?? "")} />
|
||||||
|
<input type="hidden" name="height" value={String(p.height ?? "")} />
|
||||||
|
<input type="hidden" name="framerate" value={String(p.framerate ?? "")} />
|
||||||
|
<input type="hidden" name="profile_token" value={p.profile_token} />
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Add</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:1rem">
|
||||||
|
<a href="/admin/cameras/discover" class="btn btn-ghost">Discover Another</a>
|
||||||
|
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Back to Cameras</a>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Entities ---------------------------------------------------------------
|
||||||
|
|
||||||
|
interface EntitiesPageProps {
|
||||||
|
user: string;
|
||||||
|
entities: Entity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityBadge(type: string) {
|
||||||
|
const cls = type === "camera" ? "badge-blue" : type === "web" ? "badge-green" : "badge-gray";
|
||||||
|
return <span class={`badge ${cls}`}>{type}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Layout title="Entities" user={props.user} activeNav="entities">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">All Entities</h2>
|
||||||
|
<a href="/admin/entities/new" class="btn btn-primary">New Entity</a>
|
||||||
|
</div>
|
||||||
|
<p style="color:#666; margin-bottom:1.25rem">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.entities.length === 0 ? (
|
||||||
|
<tr><td colspan="3" style="text-align:center; color:#999; padding:2rem">No entities yet</td></tr>
|
||||||
|
) : (
|
||||||
|
props.entities.map((e) => (
|
||||||
|
<tr>
|
||||||
|
<td><a href={`/admin/entities/${e.id}`}><strong>{e.name}</strong></a></td>
|
||||||
|
<td>{entityBadge(e.type)}</td>
|
||||||
|
<td style="color:#666; font-size:0.85rem">{entityDetail(e)}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EntityNewPageProps {
|
||||||
|
user: string;
|
||||||
|
cameras: Camera[];
|
||||||
|
error?: string;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityFormScript(rootId: string): string {
|
||||||
|
// Show/hide type-specific fieldsets when the type select changes.
|
||||||
|
return (
|
||||||
`(function(){` +
|
`(function(){` +
|
||||||
`var radios=document.querySelectorAll('input[name="type"]');` +
|
`var root=document.getElementById('${rootId}');` +
|
||||||
`var rd=document.getElementById("rtsp-fields");` +
|
`if(!root)return;` +
|
||||||
`var od=document.getElementById("onvif-fields");` +
|
`var sel=root.querySelector('select[name="type"]');` +
|
||||||
`function t(){var el=document.querySelector('input[name="type"]:checked');` +
|
`var cf=root.querySelector('.ent-fields-camera');` +
|
||||||
`var v=el?el.value:"rtsp";` +
|
`var wf=root.querySelector('.ent-fields-web');` +
|
||||||
`if(rd)rd.style.display=v==="rtsp"?"block":"none";` +
|
`var hf=root.querySelector('.ent-fields-html');` +
|
||||||
`if(od)od.style.display=v==="onvif"?"block":"none";}` +
|
`function t(){var v=sel?sel.value:"html";` +
|
||||||
`radios.forEach(function(r){r.addEventListener("change",t)});t();})()`
|
`if(cf)cf.style.display=v==='camera'?'block':'none';` +
|
||||||
)}</script>
|
`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 (
|
||||||
|
<Layout
|
||||||
|
title="New Entity"
|
||||||
|
user={props.user}
|
||||||
|
activeNav="entities"
|
||||||
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
||||||
|
>
|
||||||
|
<div id="entity-new-root" style="max-width:600px">
|
||||||
|
<form method="post" action="/admin/entities/new" class="card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" type="text" class="form-input" required maxlength="128" value={v["name"] ?? ""} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="type">Type</label>
|
||||||
|
<select id="type" name="type" class="form-input">
|
||||||
|
<option value="html" selected={selType === "html"}>HTML snippet</option>
|
||||||
|
<option value="web" selected={selType === "web"}>Web URL</option>
|
||||||
|
<option value="camera" selected={selType === "camera"}>Camera reference</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description (optional)</label>
|
||||||
|
<input id="description" name="description" type="text" class="form-input" value={v["description"] ?? ""} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ent-fields-camera" style={selType === "camera" ? "" : "display:none"}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="camera_id">Camera</label>
|
||||||
|
<select id="camera_id" name="camera_id" class="form-input">
|
||||||
|
<option value="">-- Select --</option>
|
||||||
|
{props.cameras.map((cam) => (
|
||||||
|
<option value={String(cam.id)} selected={v["camera_id"] === String(cam.id)}>{cam.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ent-fields-web" style={selType === "web" ? "" : "display:none"}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="web_url">URL</label>
|
||||||
|
<input id="web_url" name="web_url" type="url" class="form-input" placeholder="https://example.com" value={v["web_url"] ?? ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ent-fields-html" style={selType === "html" ? "" : "display:none"}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="html_content">HTML</label>
|
||||||
|
<textarea id="html_content" name="html_content" class="form-input" rows="6">{v["html_content"] ?? ""}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Create Entity</button>
|
||||||
|
<a href="/admin/entities" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script>{js(entityFormScript("entity-new-root"))}</script>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EntityEditPageProps {
|
||||||
|
user: string;
|
||||||
|
entity: Entity;
|
||||||
|
cameras: Camera[];
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityEditPage(props: EntityEditPageProps) {
|
||||||
|
const e = props.entity;
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title={`Entity: ${e.name}`}
|
||||||
|
user={props.user}
|
||||||
|
activeNav="entities"
|
||||||
|
flash={
|
||||||
|
props.error ? { type: "error", message: props.error }
|
||||||
|
: props.success ? { type: "success", message: props.success }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div id="entity-edit-root" style="max-width:600px">
|
||||||
|
<div class="card" style="margin-bottom:1rem">
|
||||||
|
<div style="margin-bottom:0.75rem">Type: {entityBadge(e.type)}</div>
|
||||||
|
<form method="post" action={`/admin/entities/${e.id}`}>
|
||||||
|
{/* Type is fixed after creation — switching type would break attached cells. */}
|
||||||
|
<input type="hidden" name="type" value={e.type} />
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" type="text" class="form-input" required value={e.name} maxlength="128" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<input id="description" name="description" type="text" class="form-input" value={e.description ?? ""} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{e.type === "camera" && (
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="camera_id">Camera</label>
|
||||||
|
<select id="camera_id" name="camera_id" class="form-input">
|
||||||
|
<option value="">-- Select --</option>
|
||||||
|
{props.cameras.map((cam) => (
|
||||||
|
<option value={String(cam.id)} selected={e.camera_id === cam.id}>{cam.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{e.type === "web" && (
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="web_url">URL</label>
|
||||||
|
<input id="web_url" name="web_url" type="url" class="form-input" value={e.web_url ?? ""} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{e.type === "html" && (
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="html_content">HTML</label>
|
||||||
|
<textarea id="html_content" name="html_content" class="form-input" rows="8">{e.html_content ?? ""}</textarea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="/admin/entities" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action={`/admin/entities/${e.id}/delete`}>
|
||||||
|
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this entity? Cells using it will be left empty.')"}}>Delete Entity</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1013,6 +1324,7 @@ interface LayoutEditPageProps {
|
||||||
displays: Display[];
|
displays: Display[];
|
||||||
cells: LayoutCell[];
|
cells: LayoutCell[];
|
||||||
cameras: Camera[];
|
cameras: Camera[];
|
||||||
|
entities: Entity[];
|
||||||
/** If set, render the content-assignment form for this cell beneath the grid. */
|
/** If set, render the content-assignment form for this cell beneath the grid. */
|
||||||
selectedCellId?: number | null;
|
selectedCellId?: number | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
@ -1050,7 +1362,15 @@ export const LAYOUT_BUILDER_CSS = `
|
||||||
.layout-empty-add:hover { background: #1e40af; }
|
.layout-empty-add:hover { background: #1e40af; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function cellLabel(c: LayoutCell, cameraById: Map<number, Camera>): string {
|
function cellLabel(
|
||||||
|
c: LayoutCell,
|
||||||
|
entityById: Map<number, Entity>,
|
||||||
|
cameraById: Map<number, Camera>,
|
||||||
|
): 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) {
|
if (c.content_type === "camera" && c.camera_id) {
|
||||||
return cameraById.get(c.camera_id)?.name ?? `cam #${String(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(
|
export function renderCell(
|
||||||
layoutId: number,
|
layoutId: number,
|
||||||
c: LayoutCell,
|
c: LayoutCell,
|
||||||
|
entities: Entity[],
|
||||||
cameras: Camera[],
|
cameras: Camera[],
|
||||||
mode: "read" | "edit",
|
mode: "read" | "edit",
|
||||||
): string {
|
): string {
|
||||||
const cameraById = new Map<number, Camera>();
|
const cameraById = new Map<number, Camera>();
|
||||||
for (const cam of cameras) cameraById.set(cam.id, cam);
|
for (const cam of cameras) cameraById.set(cam.id, cam);
|
||||||
|
const entityById = new Map<number, Entity>();
|
||||||
|
for (const e of entities) entityById.set(e.id, e);
|
||||||
const style = cellGridStyle(c);
|
const style = cellGridStyle(c);
|
||||||
const cellGetUrl = `/admin/layouts/${String(layoutId)}/cells/${String(c.id)}`;
|
const cellGetUrl = `/admin/layouts/${String(layoutId)}/cells/${String(c.id)}`;
|
||||||
const cellEditUrl = `${cellGetUrl}/edit`;
|
const cellEditUrl = `${cellGetUrl}/edit`;
|
||||||
|
|
@ -1093,25 +1416,21 @@ export function renderCell(
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Content Type</label>
|
<label>Entity</label>
|
||||||
<div class="radio-group" style="display:flex; gap:0.5rem; flex-wrap:wrap; font-size:0.75rem">
|
<select name="entity_id" class="form-input">
|
||||||
<label><input type="radio" name="content_type" value="camera" checked={c.content_type === "camera"} /> Camera</label>
|
<option value="">-- Empty --</option>
|
||||||
<label><input type="radio" name="content_type" value="web" checked={c.content_type === "web"} /> Web</label>
|
{entities.map((e) => (
|
||||||
<label><input type="radio" name="content_type" value="html" checked={c.content_type === "html"} /> HTML</label>
|
<option value={String(e.id)} selected={c.entity_id === e.id}>
|
||||||
|
[{e.type}] {e.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div class="form-hint" style="font-size:0.7rem">
|
||||||
|
<a href="/admin/entities/new" target="_blank">+ New entity</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id={`cell-camera-fields-${String(c.id)}`} class="cell-fields-camera" style={c.content_type === "camera" ? "" : "display:none"}>
|
<div class="form-group" style={(c.entity_id != null && entityById.get(c.entity_id)?.type === "camera") ? "" : "display:none"}>
|
||||||
<div class="form-group">
|
|
||||||
<label>Camera</label>
|
|
||||||
<select name="camera_id" class="form-input">
|
|
||||||
<option value="">-- Select --</option>
|
|
||||||
{cameras.map((cam) => (
|
|
||||||
<option value={String(cam.id)} selected={c.camera_id === cam.id}>{cam.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Stream</label>
|
<label>Stream</label>
|
||||||
<select name="stream_selector" class="form-input">
|
<select name="stream_selector" class="form-input">
|
||||||
<option value="auto" selected={c.stream_selector === "auto"}>Auto</option>
|
<option value="auto" selected={c.stream_selector === "auto"}>Auto</option>
|
||||||
|
|
@ -1119,21 +1438,6 @@ export function renderCell(
|
||||||
<option value="sub" selected={c.stream_selector === "sub"}>Sub</option>
|
<option value="sub" selected={c.stream_selector === "sub"}>Sub</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id={`cell-web-fields-${String(c.id)}`} class="cell-fields-web" style={c.content_type === "web" ? "" : "display:none"}>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>URL</label>
|
|
||||||
<input name="web_url" type="url" class="form-input" placeholder="https://example.com" value={c.web_url ?? ""} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id={`cell-html-fields-${String(c.id)}`} class="cell-fields-html" style={c.content_type === "html" ? "" : "display:none"}>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>HTML</label>
|
|
||||||
<textarea name="html_content" class="form-input" rows="3" placeholder="<div>...</div>">{c.html_content ?? ""}</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group span-grid">
|
<div class="form-group span-grid">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -1165,30 +1469,18 @@ export function renderCell(
|
||||||
>Delete</button>
|
>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<script>{js(
|
|
||||||
`(function(){` +
|
|
||||||
`var root=document.getElementById('cell-${String(c.id)}');` +
|
|
||||||
`if(!root)return;` +
|
|
||||||
`var rs=root.querySelectorAll('input[name="content_type"]');` +
|
|
||||||
`var cf=root.querySelector('.cell-fields-camera');` +
|
|
||||||
`var wf=root.querySelector('.cell-fields-web');` +
|
|
||||||
`var hf=root.querySelector('.cell-fields-html');` +
|
|
||||||
`function t(){var el=root.querySelector('input[name="content_type"]:checked');` +
|
|
||||||
`var v=el?el.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";}` +
|
|
||||||
`rs.forEach(function(r){r.addEventListener("change",t)});t();})()`
|
|
||||||
)}</script>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read mode.
|
// Read mode. Empty when no entity is bound.
|
||||||
const isEmpty = (c.content_type === "html" && !c.html_content)
|
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 === "camera" && !c.camera_id)
|
||||||
|| (c.content_type === "web" && !c.web_url);
|
|| (c.content_type === "web" && !c.web_url)
|
||||||
const label = cellLabel(c, cameraById);
|
);
|
||||||
|
const label = cellLabel(c, entityById, cameraById);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1280,6 +1572,7 @@ export function renderCell(
|
||||||
export function renderGrid(
|
export function renderGrid(
|
||||||
layoutId: number,
|
layoutId: number,
|
||||||
cells: LayoutCell[],
|
cells: LayoutCell[],
|
||||||
|
entities: Entity[],
|
||||||
cameras: Camera[],
|
cameras: Camera[],
|
||||||
): string {
|
): string {
|
||||||
if (cells.length === 0) {
|
if (cells.length === 0) {
|
||||||
|
|
@ -1313,7 +1606,7 @@ export function renderGrid(
|
||||||
class="layout-builder"
|
class="layout-builder"
|
||||||
style={`grid-template-columns:repeat(${String(gridCols)}, 1fr); grid-template-rows:repeat(${String(gridRows)}, 1fr)`}
|
style={`grid-template-columns:repeat(${String(gridCols)}, 1fr); grid-template-rows:repeat(${String(gridRows)}, 1fr)`}
|
||||||
>
|
>
|
||||||
{cells.map((c) => renderCell(layoutId, c, cameras, "read"))}
|
{cells.map((c) => renderCell(layoutId, c, entities, cameras, "read"))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1408,7 +1701,7 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
|
||||||
Click a cell to edit content in-place.
|
Click a cell to edit content in-place.
|
||||||
</p>
|
</p>
|
||||||
<div id="layout-grid">
|
<div id="layout-grid">
|
||||||
{renderGrid(l.id, cells, props.cameras)}
|
{renderGrid(l.id, cells, props.entities, props.cameras)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ function Sidebar(props: { activeNav?: string }) {
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<NavItem href="/admin/" label="Overview" icon="■" active={a === "overview"} />
|
<NavItem href="/admin/" label="Overview" icon="■" active={a === "overview"} />
|
||||||
<NavItem href="/admin/cameras" label="Cameras" icon="⚫" active={a === "cameras"} />
|
<NavItem href="/admin/cameras" label="Cameras" icon="⚫" active={a === "cameras"} />
|
||||||
|
<NavItem href="/admin/entities" label="Entities" icon="⚇" active={a === "entities"} />
|
||||||
<NavItem href="/admin/layouts" label="Layouts" icon="▦" active={a === "layouts"} />
|
<NavItem href="/admin/layouts" label="Layouts" icon="▦" active={a === "layouts"} />
|
||||||
<NavItem href="/admin/displays" label="Displays" icon="▪" active={a === "displays"} />
|
<NavItem href="/admin/displays" label="Displays" icon="▪" active={a === "displays"} />
|
||||||
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue