diff --git a/deploy/docker/sec-config.yaml b/deploy/docker/sec-config.yaml index 2e4290c..4eda271 100644 --- a/deploy/docker/sec-config.yaml +++ b/deploy/docker/sec-config.yaml @@ -35,6 +35,7 @@ default: argon2Parallelism: 2 cookieName: betterframe_session totpIssuer: BetterFrame + noderedUrl: http://nodered:1880 service-api-http: plugin: service-api-http diff --git a/nodered/README.md b/nodered/README.md new file mode 100644 index 0000000..d425266 --- /dev/null +++ b/nodered/README.md @@ -0,0 +1,47 @@ +# @betterframe/nodered-nodes + +BetterFrame integration nodes for Node-RED. Drag-and-droppable nodes for the +BetterFrame admin REST API and kiosk event ingest. + +## Nodes + +| Node | Type | Purpose | +| --- | --- | --- | +| `bf-config` | config | Shared server URL + admin API key | +| `bf-event-in` | input | Filter incoming kiosk events by topic glob | +| `bf-layout-switch` | action | Switch a display's active layout | +| `bf-power` | action | Wake / standby a kiosk display | +| `bf-fan` | action | Set fan mode (auto/pwm) on a kiosk | +| `bf-cameras` | query | Fetch the camera list | + +## Authentication + +All action/query nodes use an **admin-scoped API key** created in the +BetterFrame admin UI. The key is sent as `Authorization: Bearer bf-...`. +Configure once on a `bf-config` node and reference it from the others. + +## Event ingest path + +`bf-event-in` is a pure filter — it does not subscribe to the BF server. +Wire an upstream `http in` node on `/in/kiosk/` (BetterFrame's +authenticated kiosk-ingest endpoint, surfaced by the Angie proxy with +`auth_request` gating) and feed its `msg.payload` into `bf-event-in`. + +## Installation + +### Dev (single-host BetterFrame install) + +```sh +# Symlink the package into Node-RED's user dir so edits hot-reload. +ln -s "$(pwd)/nodered" ~/.node-red/node_modules/@betterframe/nodered-nodes +# Restart Node-RED. +``` + +### Docker compose + +The compose stack mounts `nodered-data` as `/data`. Either: + +- bake the package into the Node-RED image by extending the Dockerfile with + `npm install /repo/nodered`, or +- mount `./nodered` into `/data/node_modules/@betterframe/nodered-nodes` and + restart the container. diff --git a/nodered/icons/betterframe.svg b/nodered/icons/betterframe.svg new file mode 100644 index 0000000..fea1ba6 --- /dev/null +++ b/nodered/icons/betterframe.svg @@ -0,0 +1,22 @@ + + BetterFrame mark + A display frame with a multi-camera grid and highlighted active frame. + + + + + + + + + + + + + + + + + + + diff --git a/nodered/package.json b/nodered/package.json new file mode 100644 index 0000000..2de436f --- /dev/null +++ b/nodered/package.json @@ -0,0 +1,24 @@ +{ + "name": "@betterframe/nodered-nodes", + "version": "0.1.0", + "description": "BetterFrame integration nodes for Node-RED.", + "license": "AGPL-3.0-only OR Commercial", + "keywords": [ + "node-red", + "betterframe" + ], + "node-red": { + "version": ">=3.0.0", + "nodes": { + "bf-config": "src/bf-config.js", + "bf-event-in": "src/bf-event-in.js", + "bf-layout-switch": "src/bf-layout-switch.js", + "bf-power": "src/bf-power.js", + "bf-fan": "src/bf-fan.js", + "bf-cameras": "src/bf-cameras.js" + }, + "icons": [ + "icons" + ] + } +} diff --git a/nodered/src/bf-cameras.html b/nodered/src/bf-cameras.html new file mode 100644 index 0000000..8e4ac71 --- /dev/null +++ b/nodered/src/bf-cameras.html @@ -0,0 +1,37 @@ + + + diff --git a/nodered/src/bf-cameras.js b/nodered/src/bf-cameras.js new file mode 100644 index 0000000..4c8876f --- /dev/null +++ b/nodered/src/bf-cameras.js @@ -0,0 +1,49 @@ +/** + * bf-cameras — query the BF admin for the camera list. Emits a message + * whose `msg.payload` is an array of cameras: + * [{id, name, type, enabled, labels: [...]}, ...] + * + * Optional filter: `config.label` — if set, only include cameras carrying + * that label name (or msg.label override). + * + * Use this for populating UI dropdowns or driving "all cameras" loops. + */ +module.exports = function (RED) { + function BfCamerasNode(config) { + RED.nodes.createNode(this, config); + const node = this; + const cfg = RED.nodes.getNode(config.config); + + node.on("input", async (msg, send, done) => { + if (!cfg || !cfg.server_url || !cfg.api_key) { + node.status({ fill: "red", shape: "ring", text: "missing bf-config" }); + return done(new Error("bf-config server_url + api_key required")); + } + const filterLabel = (msg.label || config.label || "").trim().toLowerCase(); + const url = cfg.server_url + "/api/admin/cameras"; + try { + const r = await fetch(url, { + method: "GET", + headers: { + authorization: "Bearer " + cfg.api_key, + accept: "application/json", + }, + }); + if (!r.ok) throw new Error("HTTP " + r.status); + const data = await r.json(); + let cameras = Array.isArray(data) ? data : (data.cameras || []); + if (filterLabel) { + cameras = cameras.filter((c) => Array.isArray(c.labels) && c.labels.indexOf(filterLabel) >= 0); + } + node.status({ fill: "green", shape: "dot", text: String(cameras.length) + " cameras" }); + msg.payload = cameras; + send(msg); + done(); + } catch (err) { + node.status({ fill: "red", shape: "ring", text: err.message }); + done(err); + } + }); + } + RED.nodes.registerType("bf-cameras", BfCamerasNode); +}; diff --git a/nodered/src/bf-config.html b/nodered/src/bf-config.html new file mode 100644 index 0000000..3147507 --- /dev/null +++ b/nodered/src/bf-config.html @@ -0,0 +1,34 @@ + + + diff --git a/nodered/src/bf-config.js b/nodered/src/bf-config.js new file mode 100644 index 0000000..d11fd6a --- /dev/null +++ b/nodered/src/bf-config.js @@ -0,0 +1,21 @@ +/** + * bf-config — shared config node holding BetterFrame server URL + admin API key. + * + * Other bf-* action/query nodes reference this via `config.config` in their + * editor UI. The API key is treated as `credentials` so Node-RED encrypts it + * at rest. + */ +module.exports = function (RED) { + function BfConfigNode(n) { + RED.nodes.createNode(this, n); + this.name = n.name; + this.server_url = (n.server_url || "").replace(/\/+$/, ""); + // credentials.api_key is auto-merged onto `this` by Node-RED. + this.api_key = (this.credentials && this.credentials.api_key) || ""; + } + RED.nodes.registerType("bf-config", BfConfigNode, { + credentials: { + api_key: { type: "password" }, + }, + }); +}; diff --git a/nodered/src/bf-event-in.html b/nodered/src/bf-event-in.html new file mode 100644 index 0000000..833f929 --- /dev/null +++ b/nodered/src/bf-event-in.html @@ -0,0 +1,35 @@ + + + diff --git a/nodered/src/bf-event-in.js b/nodered/src/bf-event-in.js new file mode 100644 index 0000000..1ab9b29 --- /dev/null +++ b/nodered/src/bf-event-in.js @@ -0,0 +1,58 @@ +/** + * bf-event-in — fire a flow whenever a BetterFrame kiosk event matching a + * topic pattern arrives. + * + * Two delivery paths can land here: + * 1. The BF server has forwarded an authenticated kiosk event via the + * `/in/kiosk/` ingest endpoint. The flow operator wires an + * `http in` node on that path and connects it to this node — we just + * filter by topic. + * 2. A separate flow injects msg.topic + msg.payload directly. + * + * In other words, bf-event-in is a pure filter/router. It does NOT itself + * subscribe to the BF server; that wiring is done with stock Node-RED http-in + * or websocket nodes upstream. + */ +module.exports = function (RED) { + function BfEventInNode(config) { + RED.nodes.createNode(this, config); + const node = this; + const pattern = (config.topic_pattern || "").trim(); + + // Convert glob-ish pattern to RegExp: `gpio.button.*` → /^gpio\.button\..*$/ + function toRegex(p) { + if (!p) return null; + const escaped = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp("^" + escaped + "$"); + } + const re = toRegex(pattern); + + node.on("input", function (msg, send, done) { + // Common BF envelope shape: + // { topic, kiosk_id, camera_id, source_type, payload } + // We accept either a fully-formed msg or one where the body lives in + // msg.payload (typical for Node-RED http-in). + const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {}; + const topic = msg.topic || body.topic || ""; + if (!topic) { + node.status({ fill: "yellow", shape: "ring", text: "no topic" }); + return done && done(); + } + if (re && !re.test(String(topic))) { + // Filter miss — drop silently. + return done && done(); + } + const out = { + topic: String(topic), + kiosk_id: msg.kiosk_id || body.kiosk_id || body.source_kiosk_id || null, + camera_id: msg.camera_id || body.camera_id || body.source_camera_id || null, + source_type: body.source_type || null, + payload: body.payload !== undefined ? body.payload : body, + }; + node.status({ fill: "green", shape: "dot", text: out.topic }); + send(out); + done && done(); + }); + } + RED.nodes.registerType("bf-event-in", BfEventInNode); +}; diff --git a/nodered/src/bf-fan.html b/nodered/src/bf-fan.html new file mode 100644 index 0000000..d84a6d5 --- /dev/null +++ b/nodered/src/bf-fan.html @@ -0,0 +1,49 @@ + + + diff --git a/nodered/src/bf-fan.js b/nodered/src/bf-fan.js new file mode 100644 index 0000000..6058bb4 --- /dev/null +++ b/nodered/src/bf-fan.js @@ -0,0 +1,59 @@ +/** + * bf-fan — POST /admin/kiosks/:id/fan {mode, pwm}. + * + * mode=auto: BF kiosk-side hwmon thermostat controls the fan. + * mode=pwm: pwm (0..255) is sent directly. Use msg.pwm or msg.mode to + * override the configured values. + */ +module.exports = function (RED) { + function BfFanNode(config) { + RED.nodes.createNode(this, config); + const node = this; + const cfg = RED.nodes.getNode(config.config); + + node.on("input", async (msg, send, done) => { + if (!cfg || !cfg.server_url || !cfg.api_key) { + node.status({ fill: "red", shape: "ring", text: "missing bf-config" }); + return done(new Error("bf-config server_url + api_key required")); + } + const kioskId = msg.kiosk_id || config.kiosk_id; + if (!kioskId) { + node.status({ fill: "red", shape: "ring", text: "missing kiosk_id" }); + return done(new Error("kiosk_id required")); + } + const mode = (msg.mode || config.mode || "auto").toLowerCase(); + let formBody; + let label; + if (mode === "pwm") { + const pwm = Number(msg.pwm !== undefined ? msg.pwm : config.pwm) || 0; + const clamped = Math.max(0, Math.min(255, Math.round(pwm))); + formBody = "mode=pwm&pwm=" + String(clamped); + label = "pwm=" + clamped; + } else { + formBody = "mode=auto"; + label = "auto"; + } + const url = cfg.server_url + "/admin/kiosks/" + encodeURIComponent(String(kioskId)) + "/fan"; + try { + const r = await fetch(url, { + method: "POST", + headers: { + authorization: "Bearer " + cfg.api_key, + "content-type": "application/x-www-form-urlencoded", + }, + body: formBody, + redirect: "manual", + }); + if (!r.ok && r.status !== 302) throw new Error("HTTP " + r.status); + node.status({ fill: "green", shape: "dot", text: "fan " + label }); + msg.bf_result = { kiosk_id: Number(kioskId), mode, status: r.status }; + send(msg); + done(); + } catch (err) { + node.status({ fill: "red", shape: "ring", text: err.message }); + done(err); + } + }); + } + RED.nodes.registerType("bf-fan", BfFanNode); +}; diff --git a/nodered/src/bf-layout-switch.html b/nodered/src/bf-layout-switch.html new file mode 100644 index 0000000..c2d2d3f --- /dev/null +++ b/nodered/src/bf-layout-switch.html @@ -0,0 +1,41 @@ + + + diff --git a/nodered/src/bf-layout-switch.js b/nodered/src/bf-layout-switch.js new file mode 100644 index 0000000..6ef3870 --- /dev/null +++ b/nodered/src/bf-layout-switch.js @@ -0,0 +1,50 @@ +/** + * bf-layout-switch — call BF admin `POST /admin/displays/:displayId/layout/:layoutId` + * to switch a display to a specific layout. The coordinator-ws plugin then + * delivers a `layout-switch` message to the owning kiosk over WebSocket. + * + * Inputs: + * - config.display_id, config.layout_id (statically configured) + * - msg.display_id, msg.layout_id (per-message overrides) + */ +module.exports = function (RED) { + function BfLayoutSwitchNode(config) { + RED.nodes.createNode(this, config); + const node = this; + const cfg = RED.nodes.getNode(config.config); + + node.on("input", async (msg, send, done) => { + if (!cfg || !cfg.server_url || !cfg.api_key) { + node.status({ fill: "red", shape: "ring", text: "missing bf-config" }); + return done(new Error("bf-config server_url + api_key required")); + } + const displayId = msg.display_id || config.display_id; + const layoutId = msg.layout_id || config.layout_id; + if (!displayId || !layoutId) { + node.status({ fill: "red", shape: "ring", text: "missing ids" }); + return done(new Error("display_id and layout_id required")); + } + const url = cfg.server_url + "/admin/displays/" + encodeURIComponent(String(displayId)) + + "/layout/" + encodeURIComponent(String(layoutId)); + try { + const r = await fetch(url, { + method: "POST", + headers: { authorization: "Bearer " + cfg.api_key }, + redirect: "manual", + }); + // 200/302 both indicate success in BF; 302 is the post-redirect-to-admin response. + if (!r.ok && r.status !== 302) { + throw new Error("HTTP " + r.status); + } + node.status({ fill: "green", shape: "dot", text: "switched " + displayId + "→" + layoutId }); + msg.bf_result = { display_id: Number(displayId), layout_id: Number(layoutId), status: r.status }; + send(msg); + done(); + } catch (err) { + node.status({ fill: "red", shape: "ring", text: err.message }); + done(err); + } + }); + } + RED.nodes.registerType("bf-layout-switch", BfLayoutSwitchNode); +}; diff --git a/nodered/src/bf-power.html b/nodered/src/bf-power.html new file mode 100644 index 0000000..7a1a1fe --- /dev/null +++ b/nodered/src/bf-power.html @@ -0,0 +1,44 @@ + + + diff --git a/nodered/src/bf-power.js b/nodered/src/bf-power.js new file mode 100644 index 0000000..85187ff --- /dev/null +++ b/nodered/src/bf-power.js @@ -0,0 +1,50 @@ +/** + * bf-power — POST /admin/kiosks/:id/power/(wake|standby) to wake or sleep + * the display attached to a kiosk. Server fans out to the kiosk over WS, + * the kiosk then runs CEC + DPMS sequentially. + * + * config.mode: "wake" | "standby" (can also be set via msg.mode) + * config.kiosk_id: numeric (can be overridden by msg.kiosk_id) + */ +module.exports = function (RED) { + function BfPowerNode(config) { + RED.nodes.createNode(this, config); + const node = this; + const cfg = RED.nodes.getNode(config.config); + + node.on("input", async (msg, send, done) => { + if (!cfg || !cfg.server_url || !cfg.api_key) { + node.status({ fill: "red", shape: "ring", text: "missing bf-config" }); + return done(new Error("bf-config server_url + api_key required")); + } + const kioskId = msg.kiosk_id || config.kiosk_id; + const mode = (msg.mode || config.mode || "wake").toLowerCase(); + if (!kioskId) { + node.status({ fill: "red", shape: "ring", text: "missing kiosk_id" }); + return done(new Error("kiosk_id required")); + } + if (mode !== "wake" && mode !== "standby") { + node.status({ fill: "red", shape: "ring", text: "bad mode" }); + return done(new Error("mode must be wake or standby")); + } + const url = cfg.server_url + "/admin/kiosks/" + encodeURIComponent(String(kioskId)) + + "/power/" + mode; + try { + const r = await fetch(url, { + method: "POST", + headers: { authorization: "Bearer " + cfg.api_key }, + redirect: "manual", + }); + if (!r.ok && r.status !== 302) throw new Error("HTTP " + r.status); + node.status({ fill: "green", shape: "dot", text: mode + " " + kioskId }); + msg.bf_result = { kiosk_id: Number(kioskId), mode, status: r.status }; + send(msg); + done(); + } catch (err) { + node.status({ fill: "red", shape: "ring", text: err.message }); + done(err); + } + }); + } + RED.nodes.registerType("bf-power", BfPowerNode); +}; diff --git a/package-lock.json b/package-lock.json index 15d9c15..16a4861 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,10 @@ "node": ">=22" } }, + "node_modules/@betterframe/nodered-nodes": { + "resolved": "nodered", + "link": true + }, "node_modules/@betterframe/server": { "resolved": "server", "link": true @@ -968,6 +972,11 @@ "url": "https://github.com/sponsors/eemeli" } }, + "nodered": { + "name": "@betterframe/nodered-nodes", + "version": "0.1.0", + "license": "AGPL-3.0-only OR Commercial" + }, "server": { "name": "@betterframe/server", "version": "0.1.0", diff --git a/sec-config.yaml b/sec-config.yaml index 1277526..d031199 100644 --- a/sec-config.yaml +++ b/sec-config.yaml @@ -44,6 +44,7 @@ default: argon2Parallelism: 2 cookieName: betterframe_session totpIssuer: BetterFrame + noderedUrl: http://127.0.0.1:1880 # ----- Kiosk-facing REST API ----- service-api-http: diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 85fc39b..6c78f6f 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -18,6 +18,7 @@ import type { Server } from "srvx"; import { getRepo } from "../../shared/plugin-registry.js"; import { initSecrets, type SecretsApi } from "../../shared/secrets.js"; import { createAuth, type AuthApi } from "../../shared/auth.js"; +import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; import type { Repository } from "../service-store/repository.js"; import { registerMiddleware } from "./middleware.js"; @@ -46,6 +47,7 @@ const ConfigSchema = av.object( argon2Parallelism: av.int().min(1).default(2), totpIssuer: av.string().minLength(1).default("BetterFrame"), cookieName: av.string().minLength(1).default("betterframe_session"), + noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"), }, { unknownKeys: "strip" }, ); @@ -75,6 +77,7 @@ export interface AdminDeps { auth: AuthApi; secrets: SecretsApi; cookieName: string; + nodered: NoderedBridge; } // ---- Plugin ----------------------------------------------------------------- @@ -113,11 +116,17 @@ export class Plugin extends BSBService, typeof Event cookieName: this.config.cookieName, }); + const nodered = initNoderedBridge( + { baseUrl: this.config.noderedUrl }, + { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, + ); + const deps: AdminDeps = { repo, auth, secrets, cookieName: this.config.cookieName, + nodered, }; const app = new H3(); diff --git a/server/src/plugins/service-admin-http/middleware.ts b/server/src/plugins/service-admin-http/middleware.ts index 25b6593..68c1f7f 100644 --- a/server/src/plugins/service-admin-http/middleware.ts +++ b/server/src/plugins/service-admin-http/middleware.ts @@ -1,5 +1,10 @@ /** * Auth & setup gate middleware for admin-http. + * + * Accepts EITHER a valid session cookie OR an admin-scoped API key in + * `Authorization: Bearer `. API-key callers get a synthetic User + * record so downstream handlers (which always read `event.context.user`) + * keep working unchanged. */ import { type H3, getCookie, getRequestPath } from "h3"; import type { AdminDeps } from "./index.js"; @@ -9,11 +14,30 @@ declare module "h3" { interface H3EventContext { user?: User; session?: Session; + apiKeyPrefix?: string; } } +function syntheticApiKeyUser(keyPrefix: string): User { + return { + id: 0, + username: `api:${keyPrefix}`, + password_hash: "", + role: "admin", + is_active: true, + totp_enabled: false, + totp_secret_encrypted: null, + recovery_codes_hashed: [], + must_change_password: false, + failed_login_count: 0, + locked_until: null, + last_login_at: null, + created_at: new Date(0).toISOString(), + }; +} + export function registerMiddleware(app: H3, deps: AdminDeps): void { - app.use((event) => { + app.use(async (event) => { const path = getRequestPath(event); if ( @@ -39,6 +63,22 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void { } if (path.startsWith("/admin") || path.startsWith("/api/admin")) { + // ---- Bearer API key (admin scope) ------------------------------------- + // Lets Node-RED nodes + scripted automation hit /admin/* without owning + // a session cookie. Must come BEFORE the cookie redirect so a missing + // cookie + present API key doesn't 302 to /auth/login. + const authz = event.req.headers.get("authorization"); + if (authz && authz.startsWith("Bearer ")) { + const token = authz.slice(7); + const key = await deps.auth.verifyApiKey(token, event.req.headers.get("x-real-ip")); + if (!key || !key.scopes.includes("admin")) { + return new Response(null, { status: 401 }); + } + event.context.user = syntheticApiKeyUser(key.key_prefix); + event.context.apiKeyPrefix = key.key_prefix; + return; + } + const cookie = getCookie(event, deps.cookieName); if (!cookie) { return new Response(null, { status: 302, headers: { location: "/auth/login" } }); diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 8ac6595..7334ae3 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -1320,4 +1320,77 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { } return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); + + // ---- JSON API (admin scope) — used by Node-RED bf-cameras node ---------- + app.get("/api/admin/cameras", (_event) => { + const cameras = deps.repo.listCameras(); + const payload = cameras.map((c) => ({ + id: c.id, + name: c.name, + type: c.type, + enabled: c.enabled, + labels: deps.repo.cameraLabelNames(c.id), + })); + return new Response(JSON.stringify({ cameras: payload }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + // ---- Dashboard entity sync — pull tabs from Node-RED, mirror as entities -- + app.post("/admin/entities/sync-dashboards", async (event) => { + const result = await syncDashboardsFromNodered(deps); + if (isHtmxRequest(event)) { + return htmlFragment( + `
Synced: +${String(result.added)} added, ${String(result.updated)} updated, ${String(result.total)} total.
`, + ); + } + return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); + }); +} + +/** + * Pull dashboard tabs from the Node-RED runtime and mirror them as `dashboard` + * entities. Idempotent: existing entities matched by dashboard_id get name + * updates, new tabs get inserted. Tabs that no longer exist are NOT auto- + * deleted — admins might still be using a stale layout cell that points to one, + * and dashboards are cheap to leave around. + */ +async function syncDashboardsFromNodered( + deps: AdminDeps, +): Promise<{ added: number; updated: number; total: number }> { + const tabs = await deps.nodered.listDashboards(); + let added = 0; + let updated = 0; + for (const tab of tabs) { + const existing = deps.repo.getEntityForDashboard(tab.id); + if (existing) { + if (existing.name !== tab.name) { + // Avoid name collisions with non-dashboard entities of the same name. + const collision = deps.repo.getEntityByName(tab.name); + const safeName = collision && collision.id !== existing.id + ? `${tab.name} (dash ${tab.id.slice(0, 6)})` + : tab.name; + deps.repo.updateEntity(existing.id, { name: safeName }); + updated += 1; + } + continue; + } + // New dashboard tab — insert. + let name = tab.name || `Dashboard ${tab.id.slice(0, 6)}`; + if (deps.repo.getEntityByName(name)) { + name = `${name} (dash ${tab.id.slice(0, 6)})`; + } + deps.repo.createEntity({ + name, + type: "dashboard", + dashboard_id: tab.id, + description: tab.hidden ? "hidden tab" : null, + }); + added += 1; + } + if (added > 0 || updated > 0) { + try { getCoordinator().notifyBundleChanged(); } catch { /* ignore */ } + } + return { added, updated, total: tabs.length }; } diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index ee29ea6..ba971a8 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -225,6 +225,7 @@ export function rowToEntity(r: Row): Entity { camera_id: nn(r["camera_id"]), html_content: sn(r["html_content"]), web_url: sn(r["web_url"]), + dashboard_id: sn(r["dashboard_id"]), 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 4c24608..414d1af 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -602,6 +602,43 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ addColumnIfNotExists(db, "layout_cells", "fit", "TEXT NOT NULL DEFAULT 'cover'"); }, + // ---- entities.dashboard — Node-RED Dashboard tab entity type --------------- + // Adds dashboard_id column and broadens the type CHECK to include + // 'dashboard'. SQLite can't ALTER a CHECK in place — rebuild the table when + // the old constraint is detected. + (db: DatabaseSync) => { + addColumnIfNotExists(db, "entities", "dashboard_id", "TEXT"); + + const row = db + .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'entities'") + .get() as { sql?: string } | undefined; + if (!row?.sql) return; + if (row.sql.includes("'dashboard'")) return; // already migrated + + db.exec("PRAGMA foreign_keys = OFF"); + db.exec(` + CREATE TABLE entities_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL CHECK(type IN ('camera', 'html', 'web', 'dashboard')), + description TEXT, + camera_id INTEGER REFERENCES cameras(id) ON DELETE CASCADE, + html_content TEXT, + web_url TEXT, + dashboard_id TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ) STRICT; + + INSERT INTO entities_new (id, name, type, description, camera_id, html_content, web_url, dashboard_id, created_at) + SELECT id, name, type, description, camera_id, html_content, web_url, dashboard_id, created_at FROM entities; + + DROP TABLE entities; + ALTER TABLE entities_new RENAME TO entities; + CREATE INDEX IF NOT EXISTS idx_entities_camera ON entities(camera_id); + `); + db.exec("PRAGMA foreign_keys = ON"); + }, + // ---- kiosk GPIO bindings ---- `CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 4d1c265..1adc9a1 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -564,7 +564,9 @@ export class Repository { fit?: "cover" | "contain" | "fill"; }): LayoutCell { // Resolve content fields from the entity (if given). The legacy columns - // remain populated for backward-compatible bundle generation. + // remain populated for backward-compatible bundle generation. Dashboard + // entities materialise as web cells pointing at /dash/ so the existing + // kiosk's WebKit cell path renders them with no app changes. let contentType = input.content_type ?? "none"; let cameraId: number | null = input.camera_id ?? null; let webUrl: string | null = input.web_url ?? null; @@ -572,9 +574,12 @@ export class Repository { if (input.entity_id != null) { const ent = this.getEntityById(input.entity_id); if (ent) { - contentType = ent.type; + contentType = ent.type === "dashboard" ? "web" : ent.type; cameraId = ent.type === "camera" ? ent.camera_id : null; - webUrl = ent.type === "web" ? ent.web_url : null; + webUrl = + ent.type === "web" ? ent.web_url : + ent.type === "dashboard" && ent.dashboard_id ? `/dash/${ent.dashboard_id}` : + null; htmlContent = ent.type === "html" ? ent.html_content : null; } } @@ -628,6 +633,11 @@ export class Repository { } const ent = this.getEntityById(entityId); if (!ent) return; + const cellContentType = ent.type === "dashboard" ? "web" : ent.type; + const cellWebUrl = + ent.type === "web" ? ent.web_url : + ent.type === "dashboard" && ent.dashboard_id ? `/dash/${ent.dashboard_id}` : + null; this.db .prepare( `UPDATE layout_cells @@ -640,9 +650,9 @@ export class Repository { ) .run( ent.id, - ent.type, + cellContentType, ent.type === "camera" ? ent.camera_id : null, - ent.type === "web" ? ent.web_url : null, + cellWebUrl, ent.type === "html" ? ent.html_content : null, cellId, ); @@ -1261,10 +1271,11 @@ export class Repository { camera_id?: number | null; html_content?: string | null; web_url?: string | null; + dashboard_id?: string | null; }): Entity { const result = this.prep( - `INSERT INTO entities (name, type, description, camera_id, html_content, web_url) - VALUES (?, ?, ?, ?, ?, ?)`, + `INSERT INTO entities (name, type, description, camera_id, html_content, web_url, dashboard_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, ).run( input.name, input.type, @@ -1272,6 +1283,7 @@ export class Repository { input.type === "camera" ? (input.camera_id ?? null) : null, input.type === "html" ? (input.html_content ?? null) : null, input.type === "web" ? (input.web_url ?? null) : null, + input.type === "dashboard" ? (input.dashboard_id ?? null) : null, ); const id = Number(result.lastInsertRowid); void this.notify("entities", "create", id); @@ -1280,6 +1292,14 @@ export class Repository { return e; } + /** Find a dashboard entity by Node-RED tab id (used by the sync flow). */ + getEntityForDashboard(dashboardId: string): Entity | null { + const r = this.prep( + `SELECT * FROM entities WHERE type = 'dashboard' AND dashboard_id = ? LIMIT 1`, + ).get(dashboardId); + return r ? rowToEntity(r as Record) : null; + } + updateEntity( id: number, patch: { @@ -1288,6 +1308,7 @@ export class Repository { camera_id?: number | null; html_content?: string | null; web_url?: string | null; + dashboard_id?: string | null; }, ): void { const sets: string[] = []; @@ -1304,9 +1325,15 @@ export class Repository { 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. + // legacy cell columns stay aligned for bundle generation. Dashboard + // entities materialise as `web` cells pointing at /dash/. const ent = this.getEntityById(id); if (!ent) return; + const cellContentType = ent.type === "dashboard" ? "web" : ent.type; + const cellWebUrl = + ent.type === "web" ? ent.web_url : + ent.type === "dashboard" && ent.dashboard_id ? `/dash/${ent.dashboard_id}` : + null; this.db .prepare( `UPDATE layout_cells @@ -1317,9 +1344,9 @@ export class Repository { WHERE entity_id = ?`, ) .run( - ent.type, + cellContentType, ent.type === "camera" ? ent.camera_id : null, - ent.type === "web" ? ent.web_url : null, + cellWebUrl, ent.type === "html" ? ent.html_content : null, id, ); diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index eaebc33..2aae8c5 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -163,9 +163,15 @@ export function generateBundle( if (c.entity_id != null) { const ent = repo.getEntityById(c.entity_id); if (ent) { - contentType = ent.type; + // Dashboard entities are surfaced to the kiosk as `web` cells + // pointing at /dash/ — kiosk WebKit handles them + // identically to user-supplied web cells. + contentType = ent.type === "dashboard" ? "web" : ent.type; cameraId = ent.type === "camera" ? ent.camera_id : null; - webUrl = ent.type === "web" ? ent.web_url : null; + webUrl = + ent.type === "web" ? ent.web_url : + ent.type === "dashboard" && ent.dashboard_id ? `/dash/${ent.dashboard_id}` : + null; htmlContent = ent.type === "html" ? ent.html_content : null; } } diff --git a/server/src/shared/nodered-bridge.ts b/server/src/shared/nodered-bridge.ts index 2149448..5f1c88d 100644 --- a/server/src/shared/nodered-bridge.ts +++ b/server/src/shared/nodered-bridge.ts @@ -16,8 +16,61 @@ export interface NoderedLog { warn(msg: string): void; } +export interface NoderedDashboard { + /** Node-RED tab id, e.g. "abc123def456". URL becomes `/dash/`. */ + id: string; + name: string; + hidden: boolean; +} + export interface NoderedBridge { forward(topic: string, payload: Record): void; + listDashboards(): Promise; +} + +interface NoderedFlowNode { + id: string; + type: string; + label?: string; + name?: string; + hidden?: boolean; + disabled?: boolean; +} + +/** + * Pull all dashboard tabs from the Node-RED runtime's flow graph. + * Both Dashboard 1 (`ui_tab`) and Dashboard 2 (`ui-base` page) shapes get + * returned. The runtime endpoint is `/flows` under `httpAdminRoot` (which + * is `/nrdp` for BetterFrame). + */ +async function fetchDashboards(baseUrl: string, timeoutMs: number): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const url = `${baseUrl}/nrdp/flows`; + const r = await fetch(url, { + method: "GET", + headers: { accept: "application/json" }, + signal: ctrl.signal, + }); + if (!r.ok) throw new Error(`HTTP ${String(r.status)}`); + const data = (await r.json()) as NoderedFlowNode[] | { flows: NoderedFlowNode[] }; + const flows: NoderedFlowNode[] = Array.isArray(data) ? data : (data.flows ?? []); + const out: NoderedDashboard[] = []; + for (const n of flows) { + // Dashboard 1: ui_tab. Dashboard 2: ui-base "page". Treat both alike. + if (n.type === "ui_tab" || n.type === "ui-base" || n.type === "ui-page") { + out.push({ + id: n.id, + name: n.name ?? n.label ?? n.id, + hidden: Boolean(n.hidden), + }); + } + } + return out; + } finally { + clearTimeout(t); + } } export function initNoderedBridge(config: NoderedConfig, log: NoderedLog): NoderedBridge { @@ -44,5 +97,13 @@ export function initNoderedBridge(config: NoderedConfig, log: NoderedLog): Noder .catch((err) => log.warn(`nodered ${topic} failed: ${(err as Error).message}`)) .finally(() => clearTimeout(t)); }, + async listDashboards(): Promise { + try { + return await fetchDashboards(base, timeoutMs); + } catch (err) { + log.warn(`nodered listDashboards failed: ${(err as Error).message}`); + return []; + } + }, }; } diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index 7d18583..a2c34e4 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -13,7 +13,7 @@ export type StreamSelector = "auto" | "main" | "sub"; export type StreamPolicy = "auto" | "always_main" | "always_sub"; export type LayoutPriority = "hot" | "normal" | "cold"; export type CellContentType = "none" | "camera" | "web" | "html"; -export type EntityType = "camera" | "html" | "web"; +export type EntityType = "camera" | "html" | "web" | "dashboard"; export interface Entity { id: number; @@ -23,6 +23,8 @@ export interface Entity { camera_id: number | null; html_content: string | null; web_url: string | null; + /** Node-RED dashboard tab id; populated when type === "dashboard". */ + dashboard_id: string | null; created_at: string; } export type DesiredPowerState = "follow_layout" | "on" | "standby"; diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 3418371..0e65be5 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -503,7 +503,11 @@ interface EntitiesPageProps { } function entityBadge(type: string) { - const cls = type === "camera" ? "badge-blue" : type === "web" ? "badge-green" : "badge-gray"; + const cls = + type === "camera" ? "badge-blue" : + type === "web" ? "badge-green" : + type === "dashboard" ? "badge-blue" : + "badge-gray"; return {type}; } @@ -511,22 +515,31 @@ 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)}…` : "—"; + if (e.type === "dashboard") return e.dashboard_id ? `/dash/${e.dashboard_id}` : "—"; return "—"; } export function EntitiesPage(props: EntitiesPageProps) { + const dashboards = props.entities.filter((e) => e.type === "dashboard"); + const others = props.entities.filter((e) => e.type !== "dashboard"); return (

All Entities

- New Entity +
+
+ +
+ 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. + snippet, a web page, or a Node-RED dashboard tab). Bind one entity to + any number of layout cells — edit the entity once and every cell + updates.

-
+
@@ -536,10 +549,10 @@ export function EntitiesPage(props: EntitiesPageProps) { - {props.entities.length === 0 ? ( + {others.length === 0 ? ( ) : ( - props.entities.map((e) => ( + others.map((e) => ( @@ -550,6 +563,39 @@ export function EntitiesPage(props: EntitiesPageProps) {
No entities yet
{e.name} {entityBadge(e.type)}
+ +
+

Dashboards (Node-RED)

+
+

+ Auto-synced from Node-RED. Press Sync Dashboards after adding or + renaming tabs in Node-RED. Editing a dashboard happens in the Node-RED + editor. +

+
+ + + + + + + + + + {dashboards.length === 0 ? ( + + ) : ( + dashboards.map((e) => ( + + + + + + )) + )} + +
NameTab IDURL
No dashboards synced yet — press Sync.
{e.name}{e.dashboard_id ?? "—"}{e.dashboard_id ? `/dash/${e.dashboard_id}` : "—"}
+
); } @@ -729,9 +775,22 @@ export function EntityEditPage(props: EntityEditPageProps) {
)} + {e.type === "dashboard" && ( +
+ + {e.dashboard_id ?? "—"} +
+ Synced from Node-RED. Resolved as /dash/{e.dashboard_id ?? "?"} in + kiosk bundles. Edit the dashboard contents in the Node-RED editor. +
+
+ )} Back + {e.type === "dashboard" && ( + Open in Node-RED + )}