mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
feat: Node-RED custom nodes + dashboard entity type
Node-RED nodes (nodered/): - bf-config: shared server URL + admin API key - bf-event-in: filter kiosk events by topic glob - bf-layout-switch: POST display layout-switch - bf-power: kiosk wake/standby - bf-fan: kiosk fan control - bf-cameras: query camera list - Drag-droppable from Node-RED palette Server: - Admin Bearer API key auth on /admin/* (NodeRED can call admin API) - GET /api/admin/cameras for bf-cameras node - Dashboard entity type: - entities.type CHECK adds 'dashboard' - entities.dashboard_id column - shared/nodered-bridge.ts listDashboards() polls /nrdp/flows - Bundle resolves dashboard entity → web cell at /dash/<id> - POST /admin/entities/sync-dashboards mirrors Node-RED tabs - EntitiesPage shows Dashboards section + Sync button - EntityEditPage for dashboard: read-only + "Open in Node-RED" - No create/delete from BF UI — managed in Node-RED - sec-config: noderedUrl on admin-http (was already on api-http)
This commit is contained in:
parent
f40b730fe9
commit
b83782b8e0
28 changed files with 967 additions and 21 deletions
|
|
@ -35,6 +35,7 @@ default:
|
|||
argon2Parallelism: 2
|
||||
cookieName: betterframe_session
|
||||
totpIssuer: BetterFrame
|
||||
noderedUrl: http://nodered:1880
|
||||
|
||||
service-api-http:
|
||||
plugin: service-api-http
|
||||
|
|
|
|||
47
nodered/README.md
Normal file
47
nodered/README.md
Normal file
|
|
@ -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/<topic>` (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.
|
||||
22
nodered/icons/betterframe.svg
Normal file
22
nodered/icons/betterframe.svg
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
|
||||
<title id="title">BetterFrame mark</title>
|
||||
<desc id="desc">A display frame with a multi-camera grid and highlighted active frame.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bf-accent" x1="13" y1="12" x2="51" y2="52" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#38bdf8"/>
|
||||
<stop offset="0.58" stop-color="#2563eb"/>
|
||||
<stop offset="1" stop-color="#14b8a6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bf-panel" x1="12" y1="10" x2="52" y2="54" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#252547"/>
|
||||
<stop offset="1" stop-color="#111827"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="14" fill="#0f172a"/>
|
||||
<rect x="10" y="12" width="44" height="36" rx="5" fill="url(#bf-panel)" stroke="#475569" stroke-width="2"/>
|
||||
<path d="M15 22h34M15 34h34M27 17v26M39 17v26" stroke="#64748b" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M15 18a1 1 0 0 1 1-1h22a1 1 0 0 1 1 1v16H15V18z" fill="#172554" opacity=".76"/>
|
||||
<path d="M15 18a1 1 0 0 1 1-1h22a1 1 0 0 1 1 1v16H15V18z" fill="none" stroke="url(#bf-accent)" stroke-width="4" stroke-linejoin="round"/>
|
||||
<path d="M21 51h22" stroke="url(#bf-accent)" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M32 48v5" stroke="#94a3b8" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
24
nodered/package.json
Normal file
24
nodered/package.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
37
nodered/src/bf-cameras.html
Normal file
37
nodered/src/bf-cameras.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<script type="text/javascript">
|
||||
RED.nodes.registerType("bf-cameras", {
|
||||
category: "BetterFrame",
|
||||
color: "#a6d4ff",
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
config: { value: "", type: "bf-config", required: true },
|
||||
label: { value: "" },
|
||||
},
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
icon: "betterframe.svg",
|
||||
label: function () {
|
||||
return this.name || "bf cameras";
|
||||
},
|
||||
paletteLabel: "bf cameras",
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-template-name="bf-cameras">
|
||||
<div class="form-row">
|
||||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-config"><i class="fa fa-cog"></i> BF</label>
|
||||
<input type="text" id="node-input-config" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-label"><i class="fa fa-tag"></i> Filter label</label>
|
||||
<input type="text" id="node-input-label" placeholder="(blank = all)" />
|
||||
</div>
|
||||
<div class="form-tips">
|
||||
Outputs <code>msg.payload</code> = array of <code>{id, name, type, enabled, labels}</code>.
|
||||
Override label via <code>msg.label</code>.
|
||||
</div>
|
||||
</script>
|
||||
49
nodered/src/bf-cameras.js
Normal file
49
nodered/src/bf-cameras.js
Normal file
|
|
@ -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);
|
||||
};
|
||||
34
nodered/src/bf-config.html
Normal file
34
nodered/src/bf-config.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script type="text/javascript">
|
||||
RED.nodes.registerType("bf-config", {
|
||||
category: "config",
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
server_url: { value: "http://localhost", required: true },
|
||||
},
|
||||
credentials: {
|
||||
api_key: { type: "password" },
|
||||
},
|
||||
label: function () {
|
||||
return this.name || this.server_url || "bf-config";
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-template-name="bf-config">
|
||||
<div class="form-row">
|
||||
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-config-input-name" placeholder="BetterFrame" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-config-input-server_url"><i class="fa fa-globe"></i> Server URL</label>
|
||||
<input type="text" id="node-config-input-server_url" placeholder="http://localhost" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-config-input-api_key"><i class="fa fa-key"></i> Admin API key</label>
|
||||
<input type="password" id="node-config-input-api_key" placeholder="bf-..." />
|
||||
</div>
|
||||
<div class="form-tips">
|
||||
Create an admin-scoped API key in BetterFrame Admin and paste it here.
|
||||
The key is sent as <code>Authorization: Bearer <key></code>.
|
||||
</div>
|
||||
</script>
|
||||
21
nodered/src/bf-config.js
Normal file
21
nodered/src/bf-config.js
Normal file
|
|
@ -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" },
|
||||
},
|
||||
});
|
||||
};
|
||||
35
nodered/src/bf-event-in.html
Normal file
35
nodered/src/bf-event-in.html
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script type="text/javascript">
|
||||
RED.nodes.registerType("bf-event-in", {
|
||||
category: "BetterFrame",
|
||||
color: "#a6d4ff",
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
topic_pattern: { value: "" },
|
||||
},
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
icon: "betterframe.svg",
|
||||
label: function () {
|
||||
return this.name || this.topic_pattern || "bf-event-in";
|
||||
},
|
||||
paletteLabel: "bf event in",
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-template-name="bf-event-in">
|
||||
<div class="form-row">
|
||||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" placeholder="(optional)" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-topic_pattern"><i class="fa fa-filter"></i> Topic</label>
|
||||
<input type="text" id="node-input-topic_pattern" placeholder="gpio.button.*" />
|
||||
</div>
|
||||
<div class="form-tips">
|
||||
Filter incoming BF events by topic. <code>*</code> is a wildcard.
|
||||
Wire an upstream <b>http in</b> on <code>/in/kiosk/<topic></code> (or any source
|
||||
that puts the event body in <code>msg.payload</code>) to feed this node.
|
||||
On match the message becomes:
|
||||
<code>{topic, kiosk_id, camera_id, source_type, payload}</code>.
|
||||
</div>
|
||||
</script>
|
||||
58
nodered/src/bf-event-in.js
Normal file
58
nodered/src/bf-event-in.js
Normal file
|
|
@ -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/<topic>` 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);
|
||||
};
|
||||
49
nodered/src/bf-fan.html
Normal file
49
nodered/src/bf-fan.html
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script type="text/javascript">
|
||||
RED.nodes.registerType("bf-fan", {
|
||||
category: "BetterFrame",
|
||||
color: "#a6d4ff",
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
config: { value: "", type: "bf-config", required: true },
|
||||
kiosk_id: { value: "" },
|
||||
mode: { value: "auto" },
|
||||
pwm: { value: 128 },
|
||||
},
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
icon: "betterframe.svg",
|
||||
label: function () {
|
||||
return this.name || "bf fan";
|
||||
},
|
||||
paletteLabel: "bf fan",
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-template-name="bf-fan">
|
||||
<div class="form-row">
|
||||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-config"><i class="fa fa-cog"></i> BF</label>
|
||||
<input type="text" id="node-input-config" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-kiosk_id"><i class="fa fa-tv"></i> Kiosk ID</label>
|
||||
<input type="number" id="node-input-kiosk_id" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-mode"><i class="fa fa-thermometer-half"></i> Mode</label>
|
||||
<select id="node-input-mode">
|
||||
<option value="auto">auto</option>
|
||||
<option value="pwm">pwm</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-pwm"><i class="fa fa-sliders"></i> PWM</label>
|
||||
<input type="number" id="node-input-pwm" min="0" max="255" />
|
||||
</div>
|
||||
<div class="form-tips">
|
||||
<code>msg.mode</code>, <code>msg.pwm</code> override defaults.
|
||||
</div>
|
||||
</script>
|
||||
59
nodered/src/bf-fan.js
Normal file
59
nodered/src/bf-fan.js
Normal file
|
|
@ -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);
|
||||
};
|
||||
41
nodered/src/bf-layout-switch.html
Normal file
41
nodered/src/bf-layout-switch.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<script type="text/javascript">
|
||||
RED.nodes.registerType("bf-layout-switch", {
|
||||
category: "BetterFrame",
|
||||
color: "#a6d4ff",
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
config: { value: "", type: "bf-config", required: true },
|
||||
display_id: { value: "" },
|
||||
layout_id: { value: "" },
|
||||
},
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
icon: "betterframe.svg",
|
||||
label: function () {
|
||||
return this.name || "bf layout switch";
|
||||
},
|
||||
paletteLabel: "bf layout switch",
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-template-name="bf-layout-switch">
|
||||
<div class="form-row">
|
||||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-config"><i class="fa fa-cog"></i> BF</label>
|
||||
<input type="text" id="node-input-config" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-display_id"><i class="fa fa-tv"></i> Display ID</label>
|
||||
<input type="number" id="node-input-display_id" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-layout_id"><i class="fa fa-th"></i> Layout ID</label>
|
||||
<input type="number" id="node-input-layout_id" />
|
||||
</div>
|
||||
<div class="form-tips">
|
||||
Either field can be overridden by <code>msg.display_id</code> / <code>msg.layout_id</code>.
|
||||
</div>
|
||||
</script>
|
||||
50
nodered/src/bf-layout-switch.js
Normal file
50
nodered/src/bf-layout-switch.js
Normal file
|
|
@ -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);
|
||||
};
|
||||
44
nodered/src/bf-power.html
Normal file
44
nodered/src/bf-power.html
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<script type="text/javascript">
|
||||
RED.nodes.registerType("bf-power", {
|
||||
category: "BetterFrame",
|
||||
color: "#a6d4ff",
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
config: { value: "", type: "bf-config", required: true },
|
||||
kiosk_id: { value: "" },
|
||||
mode: { value: "wake" },
|
||||
},
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
icon: "betterframe.svg",
|
||||
label: function () {
|
||||
return this.name || ("bf power " + (this.mode || ""));
|
||||
},
|
||||
paletteLabel: "bf power",
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-template-name="bf-power">
|
||||
<div class="form-row">
|
||||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-config"><i class="fa fa-cog"></i> BF</label>
|
||||
<input type="text" id="node-input-config" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-kiosk_id"><i class="fa fa-tv"></i> Kiosk ID</label>
|
||||
<input type="number" id="node-input-kiosk_id" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-mode"><i class="fa fa-power-off"></i> Mode</label>
|
||||
<select id="node-input-mode">
|
||||
<option value="wake">wake</option>
|
||||
<option value="standby">standby</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-tips">
|
||||
Override via <code>msg.kiosk_id</code> and <code>msg.mode</code>.
|
||||
</div>
|
||||
</script>
|
||||
50
nodered/src/bf-power.js
Normal file
50
nodered/src/bf-power.js
Normal file
|
|
@ -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);
|
||||
};
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<InstanceType<typeof Config>, 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();
|
||||
|
|
|
|||
|
|
@ -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 <bf-...>`. 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" } });
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
`<div class="flash flash-success">Synced: +${String(result.added)} added, ${String(result.updated)} updated, ${String(result.total)} total.</div>`,
|
||||
);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/<id> 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<string, unknown>) : 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/<dashboard_id>.
|
||||
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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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/<dashboard_id> — 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`. */
|
||||
id: string;
|
||||
name: string;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface NoderedBridge {
|
||||
forward(topic: string, payload: Record<string, unknown>): void;
|
||||
listDashboards(): Promise<NoderedDashboard[]>;
|
||||
}
|
||||
|
||||
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<NoderedDashboard[]> {
|
||||
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<NoderedDashboard[]> {
|
||||
try {
|
||||
return await fetchDashboards(base, timeoutMs);
|
||||
} catch (err) {
|
||||
log.warn(`nodered listDashboards failed: ${(err as Error).message}`);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 <span class={`badge ${cls}`}>{type}</span>;
|
||||
}
|
||||
|
||||
|
|
@ -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 (
|
||||
<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 style="display:flex; gap:0.5rem">
|
||||
<form method="post" action="/admin/entities/sync-dashboards" style="display:inline">
|
||||
<button type="submit" class="btn btn-ghost">Sync Dashboards</button>
|
||||
</form>
|
||||
<a href="/admin/entities/new" class="btn btn-primary">New Entity</a>
|
||||
</div>
|
||||
</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.
|
||||
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.
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<div class="table-wrap" style="margin-bottom:1.5rem">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -536,10 +549,10 @@ export function EntitiesPage(props: EntitiesPageProps) {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.entities.length === 0 ? (
|
||||
{others.length === 0 ? (
|
||||
<tr><td colspan="3" style="text-align:center; color:#999; padding:2rem">No entities yet</td></tr>
|
||||
) : (
|
||||
props.entities.map((e) => (
|
||||
others.map((e) => (
|
||||
<tr>
|
||||
<td><a href={`/admin/entities/${e.id}`}><strong>{e.name}</strong></a></td>
|
||||
<td>{entityBadge(e.type)}</td>
|
||||
|
|
@ -550,6 +563,39 @@ export function EntitiesPage(props: EntitiesPageProps) {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Dashboards (Node-RED)</h2>
|
||||
</div>
|
||||
<p style="color:#666; margin-bottom:1rem; font-size:0.85rem">
|
||||
Auto-synced from Node-RED. Press <b>Sync Dashboards</b> after adding or
|
||||
renaming tabs in Node-RED. Editing a dashboard happens in the Node-RED
|
||||
editor.
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Tab ID</th>
|
||||
<th>URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboards.length === 0 ? (
|
||||
<tr><td colspan="3" style="text-align:center; color:#999; padding:2rem">No dashboards synced yet — press Sync.</td></tr>
|
||||
) : (
|
||||
dashboards.map((e) => (
|
||||
<tr>
|
||||
<td><a href={`/admin/entities/${e.id}`}><strong>{e.name}</strong></a></td>
|
||||
<td style="font-family:monospace; font-size:0.8rem; color:#666">{e.dashboard_id ?? "—"}</td>
|
||||
<td style="color:#666; font-size:0.85rem">{e.dashboard_id ? `/dash/${e.dashboard_id}` : "—"}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
@ -729,9 +775,22 @@ export function EntityEditPage(props: EntityEditPageProps) {
|
|||
<textarea id="html_content" name="html_content" class="form-input" rows="8">{e.html_content ?? ""}</textarea>
|
||||
</div>
|
||||
)}
|
||||
{e.type === "dashboard" && (
|
||||
<div class="form-group">
|
||||
<label>Node-RED Tab ID</label>
|
||||
<code style="display:block; padding:0.5rem; background:#f9fafb; border-radius:4px; font-size:0.85rem">{e.dashboard_id ?? "—"}</code>
|
||||
<div class="form-hint">
|
||||
Synced from Node-RED. Resolved as <code>/dash/{e.dashboard_id ?? "?"}</code> in
|
||||
kiosk bundles. Edit the dashboard contents in the Node-RED editor.
|
||||
</div>
|
||||
</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>
|
||||
{e.type === "dashboard" && (
|
||||
<a href="/admin/nodered" class="btn btn-ghost" style="margin-left:0.5rem" target="_blank" rel="noopener">Open in Node-RED</a>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue