mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +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
|
argon2Parallelism: 2
|
||||||
cookieName: betterframe_session
|
cookieName: betterframe_session
|
||||||
totpIssuer: BetterFrame
|
totpIssuer: BetterFrame
|
||||||
|
noderedUrl: http://nodered:1880
|
||||||
|
|
||||||
service-api-http:
|
service-api-http:
|
||||||
plugin: 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": ">=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@betterframe/nodered-nodes": {
|
||||||
|
"resolved": "nodered",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@betterframe/server": {
|
"node_modules/@betterframe/server": {
|
||||||
"resolved": "server",
|
"resolved": "server",
|
||||||
"link": true
|
"link": true
|
||||||
|
|
@ -968,6 +972,11 @@
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nodered": {
|
||||||
|
"name": "@betterframe/nodered-nodes",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "AGPL-3.0-only OR Commercial"
|
||||||
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"name": "@betterframe/server",
|
"name": "@betterframe/server",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ default:
|
||||||
argon2Parallelism: 2
|
argon2Parallelism: 2
|
||||||
cookieName: betterframe_session
|
cookieName: betterframe_session
|
||||||
totpIssuer: BetterFrame
|
totpIssuer: BetterFrame
|
||||||
|
noderedUrl: http://127.0.0.1:1880
|
||||||
|
|
||||||
# ----- Kiosk-facing REST API -----
|
# ----- Kiosk-facing REST API -----
|
||||||
service-api-http:
|
service-api-http:
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import type { Server } from "srvx";
|
||||||
import { getRepo } from "../../shared/plugin-registry.js";
|
import { getRepo } from "../../shared/plugin-registry.js";
|
||||||
import { initSecrets, type SecretsApi } from "../../shared/secrets.js";
|
import { initSecrets, type SecretsApi } from "../../shared/secrets.js";
|
||||||
import { createAuth, type AuthApi } from "../../shared/auth.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 type { Repository } from "../service-store/repository.js";
|
||||||
|
|
||||||
import { registerMiddleware } from "./middleware.js";
|
import { registerMiddleware } from "./middleware.js";
|
||||||
|
|
@ -46,6 +47,7 @@ const ConfigSchema = av.object(
|
||||||
argon2Parallelism: av.int().min(1).default(2),
|
argon2Parallelism: av.int().min(1).default(2),
|
||||||
totpIssuer: av.string().minLength(1).default("BetterFrame"),
|
totpIssuer: av.string().minLength(1).default("BetterFrame"),
|
||||||
cookieName: av.string().minLength(1).default("betterframe_session"),
|
cookieName: av.string().minLength(1).default("betterframe_session"),
|
||||||
|
noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"),
|
||||||
},
|
},
|
||||||
{ unknownKeys: "strip" },
|
{ unknownKeys: "strip" },
|
||||||
);
|
);
|
||||||
|
|
@ -75,6 +77,7 @@ export interface AdminDeps {
|
||||||
auth: AuthApi;
|
auth: AuthApi;
|
||||||
secrets: SecretsApi;
|
secrets: SecretsApi;
|
||||||
cookieName: string;
|
cookieName: string;
|
||||||
|
nodered: NoderedBridge;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Plugin -----------------------------------------------------------------
|
// ---- Plugin -----------------------------------------------------------------
|
||||||
|
|
@ -113,11 +116,17 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
cookieName: this.config.cookieName,
|
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 = {
|
const deps: AdminDeps = {
|
||||||
repo,
|
repo,
|
||||||
auth,
|
auth,
|
||||||
secrets,
|
secrets,
|
||||||
cookieName: this.config.cookieName,
|
cookieName: this.config.cookieName,
|
||||||
|
nodered,
|
||||||
};
|
};
|
||||||
|
|
||||||
const app = new H3();
|
const app = new H3();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
/**
|
/**
|
||||||
* Auth & setup gate middleware for admin-http.
|
* 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 H3, getCookie, getRequestPath } from "h3";
|
||||||
import type { AdminDeps } from "./index.js";
|
import type { AdminDeps } from "./index.js";
|
||||||
|
|
@ -9,11 +14,30 @@ declare module "h3" {
|
||||||
interface H3EventContext {
|
interface H3EventContext {
|
||||||
user?: User;
|
user?: User;
|
||||||
session?: Session;
|
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 {
|
export function registerMiddleware(app: H3, deps: AdminDeps): void {
|
||||||
app.use((event) => {
|
app.use(async (event) => {
|
||||||
const path = getRequestPath(event);
|
const path = getRequestPath(event);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -39,6 +63,22 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path.startsWith("/admin") || path.startsWith("/api/admin")) {
|
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);
|
const cookie = getCookie(event, deps.cookieName);
|
||||||
if (!cookie) {
|
if (!cookie) {
|
||||||
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
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}` } });
|
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"]),
|
camera_id: nn(r["camera_id"]),
|
||||||
html_content: sn(r["html_content"]),
|
html_content: sn(r["html_content"]),
|
||||||
web_url: sn(r["web_url"]),
|
web_url: sn(r["web_url"]),
|
||||||
|
dashboard_id: sn(r["dashboard_id"]),
|
||||||
created_at: s(r["created_at"]),
|
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'");
|
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 ----
|
// ---- kiosk GPIO bindings ----
|
||||||
`CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings (
|
`CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
|
||||||
|
|
@ -564,7 +564,9 @@ export class Repository {
|
||||||
fit?: "cover" | "contain" | "fill";
|
fit?: "cover" | "contain" | "fill";
|
||||||
}): LayoutCell {
|
}): LayoutCell {
|
||||||
// Resolve content fields from the entity (if given). The legacy columns
|
// 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 contentType = input.content_type ?? "none";
|
||||||
let cameraId: number | null = input.camera_id ?? null;
|
let cameraId: number | null = input.camera_id ?? null;
|
||||||
let webUrl: string | null = input.web_url ?? null;
|
let webUrl: string | null = input.web_url ?? null;
|
||||||
|
|
@ -572,9 +574,12 @@ export class Repository {
|
||||||
if (input.entity_id != null) {
|
if (input.entity_id != null) {
|
||||||
const ent = this.getEntityById(input.entity_id);
|
const ent = this.getEntityById(input.entity_id);
|
||||||
if (ent) {
|
if (ent) {
|
||||||
contentType = ent.type;
|
contentType = ent.type === "dashboard" ? "web" : ent.type;
|
||||||
cameraId = ent.type === "camera" ? ent.camera_id : null;
|
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;
|
htmlContent = ent.type === "html" ? ent.html_content : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -628,6 +633,11 @@ export class Repository {
|
||||||
}
|
}
|
||||||
const ent = this.getEntityById(entityId);
|
const ent = this.getEntityById(entityId);
|
||||||
if (!ent) return;
|
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
|
this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE layout_cells
|
`UPDATE layout_cells
|
||||||
|
|
@ -640,9 +650,9 @@ export class Repository {
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
ent.id,
|
ent.id,
|
||||||
ent.type,
|
cellContentType,
|
||||||
ent.type === "camera" ? ent.camera_id : null,
|
ent.type === "camera" ? ent.camera_id : null,
|
||||||
ent.type === "web" ? ent.web_url : null,
|
cellWebUrl,
|
||||||
ent.type === "html" ? ent.html_content : null,
|
ent.type === "html" ? ent.html_content : null,
|
||||||
cellId,
|
cellId,
|
||||||
);
|
);
|
||||||
|
|
@ -1261,10 +1271,11 @@ export class Repository {
|
||||||
camera_id?: number | null;
|
camera_id?: number | null;
|
||||||
html_content?: string | null;
|
html_content?: string | null;
|
||||||
web_url?: string | null;
|
web_url?: string | null;
|
||||||
|
dashboard_id?: string | null;
|
||||||
}): Entity {
|
}): Entity {
|
||||||
const result = this.prep(
|
const result = this.prep(
|
||||||
`INSERT INTO entities (name, type, description, camera_id, html_content, web_url)
|
`INSERT INTO entities (name, type, description, camera_id, html_content, web_url, dashboard_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
input.name,
|
input.name,
|
||||||
input.type,
|
input.type,
|
||||||
|
|
@ -1272,6 +1283,7 @@ export class Repository {
|
||||||
input.type === "camera" ? (input.camera_id ?? null) : null,
|
input.type === "camera" ? (input.camera_id ?? null) : null,
|
||||||
input.type === "html" ? (input.html_content ?? null) : null,
|
input.type === "html" ? (input.html_content ?? null) : null,
|
||||||
input.type === "web" ? (input.web_url ?? null) : null,
|
input.type === "web" ? (input.web_url ?? null) : null,
|
||||||
|
input.type === "dashboard" ? (input.dashboard_id ?? null) : null,
|
||||||
);
|
);
|
||||||
const id = Number(result.lastInsertRowid);
|
const id = Number(result.lastInsertRowid);
|
||||||
void this.notify("entities", "create", id);
|
void this.notify("entities", "create", id);
|
||||||
|
|
@ -1280,6 +1292,14 @@ export class Repository {
|
||||||
return e;
|
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(
|
updateEntity(
|
||||||
id: number,
|
id: number,
|
||||||
patch: {
|
patch: {
|
||||||
|
|
@ -1288,6 +1308,7 @@ export class Repository {
|
||||||
camera_id?: number | null;
|
camera_id?: number | null;
|
||||||
html_content?: string | null;
|
html_content?: string | null;
|
||||||
web_url?: string | null;
|
web_url?: string | null;
|
||||||
|
dashboard_id?: string | null;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
const sets: string[] = [];
|
const sets: string[] = [];
|
||||||
|
|
@ -1304,9 +1325,15 @@ export class Repository {
|
||||||
void this.notify("entities", "update", id);
|
void this.notify("entities", "update", id);
|
||||||
|
|
||||||
// Propagate content fields into any cell that uses this entity, so the
|
// 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);
|
const ent = this.getEntityById(id);
|
||||||
if (!ent) return;
|
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
|
this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE layout_cells
|
`UPDATE layout_cells
|
||||||
|
|
@ -1317,9 +1344,9 @@ export class Repository {
|
||||||
WHERE entity_id = ?`,
|
WHERE entity_id = ?`,
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
ent.type,
|
cellContentType,
|
||||||
ent.type === "camera" ? ent.camera_id : null,
|
ent.type === "camera" ? ent.camera_id : null,
|
||||||
ent.type === "web" ? ent.web_url : null,
|
cellWebUrl,
|
||||||
ent.type === "html" ? ent.html_content : null,
|
ent.type === "html" ? ent.html_content : null,
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -163,9 +163,15 @@ export function generateBundle(
|
||||||
if (c.entity_id != null) {
|
if (c.entity_id != null) {
|
||||||
const ent = repo.getEntityById(c.entity_id);
|
const ent = repo.getEntityById(c.entity_id);
|
||||||
if (ent) {
|
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;
|
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;
|
htmlContent = ent.type === "html" ? ent.html_content : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,61 @@ export interface NoderedLog {
|
||||||
warn(msg: string): void;
|
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 {
|
export interface NoderedBridge {
|
||||||
forward(topic: string, payload: Record<string, unknown>): void;
|
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 {
|
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}`))
|
.catch((err) => log.warn(`nodered ${topic} failed: ${(err as Error).message}`))
|
||||||
.finally(() => clearTimeout(t));
|
.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 StreamPolicy = "auto" | "always_main" | "always_sub";
|
||||||
export type LayoutPriority = "hot" | "normal" | "cold";
|
export type LayoutPriority = "hot" | "normal" | "cold";
|
||||||
export type CellContentType = "none" | "camera" | "web" | "html";
|
export type CellContentType = "none" | "camera" | "web" | "html";
|
||||||
export type EntityType = "camera" | "html" | "web";
|
export type EntityType = "camera" | "html" | "web" | "dashboard";
|
||||||
|
|
||||||
export interface Entity {
|
export interface Entity {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -23,6 +23,8 @@ export interface Entity {
|
||||||
camera_id: number | null;
|
camera_id: number | null;
|
||||||
html_content: string | null;
|
html_content: string | null;
|
||||||
web_url: string | null;
|
web_url: string | null;
|
||||||
|
/** Node-RED dashboard tab id; populated when type === "dashboard". */
|
||||||
|
dashboard_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
export type DesiredPowerState = "follow_layout" | "on" | "standby";
|
export type DesiredPowerState = "follow_layout" | "on" | "standby";
|
||||||
|
|
|
||||||
|
|
@ -503,7 +503,11 @@ interface EntitiesPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function entityBadge(type: string) {
|
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>;
|
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 === "camera") return e.camera_id ? `cam #${String(e.camera_id)}` : "—";
|
||||||
if (e.type === "web") return e.web_url ?? "—";
|
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 === "html") return e.html_content ? `${e.html_content.slice(0, 80)}…` : "—";
|
||||||
|
if (e.type === "dashboard") return e.dashboard_id ? `/dash/${e.dashboard_id}` : "—";
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EntitiesPage(props: EntitiesPageProps) {
|
export function EntitiesPage(props: EntitiesPageProps) {
|
||||||
|
const dashboards = props.entities.filter((e) => e.type === "dashboard");
|
||||||
|
const others = props.entities.filter((e) => e.type !== "dashboard");
|
||||||
return (
|
return (
|
||||||
<Layout title="Entities" user={props.user} activeNav="entities">
|
<Layout title="Entities" user={props.user} activeNav="entities">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2 class="section-title">All Entities</h2>
|
<h2 class="section-title">All Entities</h2>
|
||||||
|
<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>
|
<a href="/admin/entities/new" class="btn btn-primary">New Entity</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<p style="color:#666; margin-bottom:1.25rem">
|
<p style="color:#666; margin-bottom:1.25rem">
|
||||||
Entities are reusable content blocks (a camera reference, an HTML
|
Entities are reusable content blocks (a camera reference, an HTML
|
||||||
snippet, or a web page). Bind one entity to any number of layout cells —
|
snippet, a web page, or a Node-RED dashboard tab). Bind one entity to
|
||||||
edit the entity once and every cell updates.
|
any number of layout cells — edit the entity once and every cell
|
||||||
|
updates.
|
||||||
</p>
|
</p>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap" style="margin-bottom:1.5rem">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -536,10 +549,10 @@ export function EntitiesPage(props: EntitiesPageProps) {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
<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>
|
<tr>
|
||||||
<td><a href={`/admin/entities/${e.id}`}><strong>{e.name}</strong></a></td>
|
<td><a href={`/admin/entities/${e.id}`}><strong>{e.name}</strong></a></td>
|
||||||
<td>{entityBadge(e.type)}</td>
|
<td>{entityBadge(e.type)}</td>
|
||||||
|
|
@ -550,6 +563,39 @@ export function EntitiesPage(props: EntitiesPageProps) {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</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>
|
<textarea id="html_content" name="html_content" class="form-input" rows="8">{e.html_content ?? ""}</textarea>
|
||||||
</div>
|
</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>
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
<a href="/admin/entities" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue