Compare commits
3 Commits
main
...
67a374ff4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a374ff4f | ||
|
|
92d7eba0fd | ||
|
|
2874608375 |
80
CONTRACT.md
Normal file
80
CONTRACT.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# dashboardAPI — Contract
|
||||
|
||||
dashboardAPI is an EVOLV utility node that listens for child-registration
|
||||
events from other EVOLV nodes and emits Grafana dashboard upsert HTTP
|
||||
requests on Port 0. It has **no domain measurements, no tick loop, and no
|
||||
parent of its own** — it is a one-shot HTTP emitter. Per
|
||||
OPEN_QUESTIONS.md (2026-05-10) it does NOT extend `BaseNodeAdapter` /
|
||||
`BaseDomain`; it uses the shared command registry only.
|
||||
|
||||
## Inputs (msg.topic on Port 0)
|
||||
|
||||
| Canonical | Aliases (deprecated) | Payload | Effect |
|
||||
|---|---|---|---|
|
||||
| `child.register` | `registerChild` | string (child node id) **or** `{ source: {...} }` **or** `{ config: {...} }` (optionally `msg.includeChildren: boolean`, default `true`) | Resolves the child source (`RED.nodes.getNode` → `node._flow.getNode` → inline payload), calls `source.generateDashboardsForGraph(child, { includeChildren })`, then emits one `topic: 'create'` HTTP-upsert message on Port 0 per generated dashboard. |
|
||||
|
||||
Aliases log a one-time deprecation warning the first time they fire.
|
||||
|
||||
## Outputs (msg.topic on Port 0/1/2)
|
||||
|
||||
- **Port 0 (process):** one message per generated dashboard, shaped for a
|
||||
downstream `http request` node:
|
||||
```js
|
||||
{
|
||||
topic: 'create',
|
||||
url: <grafanaUpsertUrl>, // e.g. http://grafana:3000/api/dashboards/db
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer …' // only when bearerToken is set
|
||||
},
|
||||
payload: { dashboard: {…}, folderId: 0, overwrite: true },
|
||||
meta: { nodeId, softwareType, uid, title }
|
||||
}
|
||||
```
|
||||
Re-emits the inbound `msg` fields by spread (`{...msg, ...}`) so any
|
||||
caller-supplied correlation/trace fields propagate.
|
||||
- **Port 1 (InfluxDB telemetry):** **not used.** dashboardAPI has no
|
||||
measurements; nothing is emitted on Port 1.
|
||||
- **Port 2 (registration / control plumbing):** **not used.** dashboardAPI
|
||||
is a sink for `child.register`, not a source — it does not register
|
||||
itself with any parent.
|
||||
|
||||
## Events emitted by `source.emitter`
|
||||
|
||||
None. The specificClass (`DashboardApi`) exposes no `EventEmitter` — it
|
||||
is a passive service that responds to method calls and returns built
|
||||
dashboard payloads.
|
||||
|
||||
## Children accepted
|
||||
|
||||
Any EVOLV node whose `nodeSource.config` includes
|
||||
`functionality.softwareType`. The graph walk reads children via
|
||||
`nodeSource.childRegistrationUtils.registeredChildren.values()`. A
|
||||
dashboard template is loaded from `config/<softwareType>.json` (with
|
||||
case-insensitive fallback and a `machineGroupControl → machineGroup.json`
|
||||
alias); a missing template is logged at `warn` and the dashboard is
|
||||
skipped.
|
||||
|
||||
The dashboard's templating variables `measurement` and `bucket` are
|
||||
filled from the child's id and `positionVsParent` (or
|
||||
`config.defaultBucket` / `config.bucketMap[position]` overrides). The
|
||||
root dashboard is augmented with `links[]` entries pointing at each
|
||||
direct child dashboard.
|
||||
|
||||
## Why no BaseNodeAdapter / BaseDomain
|
||||
|
||||
- No `generalFunctions/src/configs/dashboardapi.json` — `BaseDomain`'s
|
||||
constructor unconditionally calls `configManager.getConfig(ctor.name)`
|
||||
and would throw. The local `dependencies/dashboardapi/dashboardapiConfig.json`
|
||||
is for the editor menu endpoint, not the runtime config pipeline.
|
||||
- No periodic output — `BaseNodeAdapter`'s `_emitOutputs()` /
|
||||
`outputUtils.formatMsg` pipeline assumes a delta-compressed Port 0/1
|
||||
stream; dashboardAPI emits HTTP-shaped messages instead.
|
||||
- No registration to a parent — `BaseNodeAdapter._scheduleRegistration`
|
||||
would emit a spurious `child.register` of its own.
|
||||
- No status badge / tick / measurements / children of its own.
|
||||
|
||||
dashboardAPI uses the shared `commandRegistry` (canonical topic naming +
|
||||
alias-with-deprecation) and stops there.
|
||||
@@ -4,7 +4,10 @@
|
||||
"description": "EVOLV Grafana dashboard generator (Node-RED node).",
|
||||
"main": "dashboardapi.js",
|
||||
"scripts": {
|
||||
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
|
||||
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js",
|
||||
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
|
||||
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
|
||||
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
|
||||
},
|
||||
"keywords": [
|
||||
"dashboard",
|
||||
|
||||
64
src/commands/handlers.js
Normal file
64
src/commands/handlers.js
Normal file
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
|
||||
// Resolve a child's source object from a registration payload.
|
||||
// Payload may be: a string (node id) | { source: {...} } | { config: {...} }.
|
||||
function resolveChildSource(payload, ctx) {
|
||||
if (payload?.source?.config) return payload.source;
|
||||
if (payload?.config) return { config: payload.config };
|
||||
if (typeof payload === 'string') {
|
||||
const childNode = resolveChildNode(payload, ctx);
|
||||
return childNode?.source || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveChildNode(childId, ctx) {
|
||||
const runtimeNode = ctx.RED?.nodes?.getNode?.(childId);
|
||||
if (runtimeNode?.source?.config) return runtimeNode;
|
||||
|
||||
const flowNode = ctx.node?._flow?.getNode?.(childId);
|
||||
if (flowNode?.source?.config) return flowNode;
|
||||
|
||||
return runtimeNode || flowNode || null;
|
||||
}
|
||||
|
||||
// On child.register: build the dashboard graph (root + direct children) and
|
||||
// emit one Grafana upsert HTTP request per dashboard on Port 0.
|
||||
function registerChild(source, msg, ctx) {
|
||||
const childSource = resolveChildSource(msg.payload, ctx);
|
||||
if (!childSource?.config) {
|
||||
throw new Error('Missing or invalid child node');
|
||||
}
|
||||
|
||||
const dashboards = source.generateDashboardsForGraph(childSource, {
|
||||
includeChildren: Boolean(msg.includeChildren ?? true),
|
||||
});
|
||||
|
||||
const url = source.grafanaUpsertUrl();
|
||||
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
|
||||
const token = source.config?.grafanaConnector?.bearerToken;
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
for (const dash of dashboards) {
|
||||
ctx.send({
|
||||
...msg,
|
||||
topic: 'create',
|
||||
url,
|
||||
method: 'POST',
|
||||
headers,
|
||||
payload: source.buildUpsertRequest({
|
||||
dashboard: dash.dashboard,
|
||||
folderId: 0,
|
||||
overwrite: true,
|
||||
}),
|
||||
meta: {
|
||||
nodeId: dash.nodeId,
|
||||
softwareType: dash.softwareType,
|
||||
uid: dash.uid,
|
||||
title: dash.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { registerChild };
|
||||
16
src/commands/index.js
Normal file
16
src/commands/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
// dashboardAPI command registry. Canonical names follow CONTRACTS.md §1.
|
||||
// The legacy `registerChild` topic is kept as an alias of `child.register`
|
||||
// (Phase 1 canonical) and logs a one-time deprecation warning on first use.
|
||||
|
||||
const handlers = require('./handlers');
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
topic: 'child.register',
|
||||
aliases: ['registerChild'],
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.registerChild,
|
||||
},
|
||||
];
|
||||
111
src/nodeClass.js
111
src/nodeClass.js
@@ -1,23 +1,36 @@
|
||||
const { configManager } = require('generalFunctions');
|
||||
'use strict';
|
||||
|
||||
// dashboardAPI nodeClass — passive HTTP-emitter adapter.
|
||||
//
|
||||
// Does NOT extend BaseNodeAdapter: dashboardAPI has no generalFunctions
|
||||
// config JSON, no Port-0/1 telemetry stream, no parent registration, no
|
||||
// tick or status loop. It just listens for `child.register` and emits one
|
||||
// Grafana upsert HTTP request per dashboard. See OPEN_QUESTIONS.md
|
||||
// (2026-05-10) for the rationale.
|
||||
|
||||
const { configManager, createRegistry } = require('generalFunctions');
|
||||
const DashboardApi = require('./specificClass');
|
||||
const commands = require('./commands');
|
||||
|
||||
class nodeClass {
|
||||
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
||||
this.node = nodeInstance;
|
||||
this.RED = RED;
|
||||
this.name = nameOfNode;
|
||||
this.source = null;
|
||||
this.config = null;
|
||||
|
||||
this._loadConfig(uiConfig);
|
||||
this._setupSpecificClass();
|
||||
this.config = this._buildConfig(uiConfig);
|
||||
this.source = new DashboardApi(this.config);
|
||||
this.node.source = this.source;
|
||||
|
||||
this._commands = createRegistry(commands, { logger: this.source?.logger });
|
||||
|
||||
this._attachInputHandler();
|
||||
this._attachCloseHandler();
|
||||
}
|
||||
|
||||
_loadConfig(uiConfig) {
|
||||
_buildConfig(uiConfig) {
|
||||
const cfgMgr = new configManager();
|
||||
this.config = cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
|
||||
return cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
|
||||
functionality: {
|
||||
softwareType: this.name.toLowerCase(),
|
||||
role: 'auto ui generator',
|
||||
@@ -32,89 +45,15 @@ class nodeClass {
|
||||
});
|
||||
}
|
||||
|
||||
_setupSpecificClass() {
|
||||
this.source = new DashboardApi(this.config);
|
||||
this.node.source = this.source;
|
||||
}
|
||||
|
||||
_resolveChildNode(childId) {
|
||||
const runtimeNode = this.RED.nodes.getNode(childId);
|
||||
if (runtimeNode?.source?.config) {
|
||||
return runtimeNode;
|
||||
}
|
||||
|
||||
const flowNode = this.node._flow?.getNode?.(childId);
|
||||
if (flowNode?.source?.config) {
|
||||
return flowNode;
|
||||
}
|
||||
|
||||
return runtimeNode || flowNode || null;
|
||||
}
|
||||
|
||||
_resolveChildSource(payload) {
|
||||
if (payload?.source?.config) {
|
||||
return payload.source;
|
||||
}
|
||||
|
||||
if (payload?.config) {
|
||||
return { config: payload.config };
|
||||
}
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
return this._resolveChildNode(payload)?.source || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_attachInputHandler() {
|
||||
this.node.on('input', async (msg, send, done) => {
|
||||
try {
|
||||
if (msg.topic !== 'registerChild') {
|
||||
if (typeof done === 'function') done();
|
||||
return;
|
||||
}
|
||||
|
||||
const childSource = this._resolveChildSource(msg.payload);
|
||||
if (!childSource?.config) {
|
||||
throw new Error('Missing or invalid child node');
|
||||
}
|
||||
|
||||
const dashboards = this.source.generateDashboardsForGraph(childSource, {
|
||||
includeChildren: Boolean(msg.includeChildren ?? true),
|
||||
await this._commands.dispatch(msg, this.source, {
|
||||
node: this.node,
|
||||
RED: this.RED,
|
||||
send,
|
||||
logger: this.source?.logger,
|
||||
});
|
||||
|
||||
const url = this.source.grafanaUpsertUrl();
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.config.grafanaConnector.bearerToken) {
|
||||
headers.Authorization = `Bearer ${this.config.grafanaConnector.bearerToken}`;
|
||||
}
|
||||
|
||||
for (const dash of dashboards) {
|
||||
send({
|
||||
...msg,
|
||||
topic: 'create',
|
||||
url,
|
||||
method: 'POST',
|
||||
headers,
|
||||
payload: this.source.buildUpsertRequest({
|
||||
dashboard: dash.dashboard,
|
||||
folderId: 0,
|
||||
overwrite: true,
|
||||
}),
|
||||
meta: {
|
||||
nodeId: dash.nodeId,
|
||||
softwareType: dash.softwareType,
|
||||
uid: dash.uid,
|
||||
title: dash.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof done === 'function') done();
|
||||
} catch (error) {
|
||||
this.node.status({ fill: 'red', shape: 'ring', text: 'dashboardapi error' });
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
describe('dashboardAPI basic structure', () => {
|
||||
it('module load smoke', () => {
|
||||
expect(() => {
|
||||
require('../../dashboardapi.js');
|
||||
}).not.toThrow();
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
test('dashboardAPI module load smoke', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
require('../../dashboardapi.js');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/basic.flow.json'), 'utf8'));
|
||||
|
||||
describe('dashboardAPI edge example structure', () => {
|
||||
it('basic example includes node type dashboardapi', () => {
|
||||
test('basic example includes node type dashboardapi', () => {
|
||||
const count = flow.filter((n) => n && n.type === 'dashboardapi').length;
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
assert.ok(count >= 1, `expected ≥1 dashboardapi node, got ${count}`);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
@@ -7,17 +9,15 @@ function loadJson(file) {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
||||
}
|
||||
|
||||
describe('dashboardAPI integration examples', () => {
|
||||
it('examples package exists for dashboardAPI', () => {
|
||||
for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
|
||||
expect(fs.existsSync(path.join(dir, file))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('example flows are parseable arrays for dashboardAPI', () => {
|
||||
for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
|
||||
const parsed = loadJson(file);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
}
|
||||
});
|
||||
test('examples package exists for dashboardAPI', () => {
|
||||
for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
|
||||
assert.ok(fs.existsSync(path.join(dir, file)), `missing ${file}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('example flows are parseable arrays for dashboardAPI', () => {
|
||||
for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
|
||||
const parsed = loadJson(file);
|
||||
assert.ok(Array.isArray(parsed), `${file} is not an array`);
|
||||
}
|
||||
});
|
||||
|
||||
226
wiki/Home.md
Normal file
226
wiki/Home.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# dashboardAPI
|
||||
|
||||
> **Reflects code as of `92d7eba` · regenerated `2026-05-11` via `npm run wiki:all`**
|
||||
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
|
||||
|
||||
## 1. What this node is
|
||||
|
||||
**dashboardAPI** is an HTTP-emitter utility node — it listens for `child.register` events from other EVOLV nodes and emits one Grafana dashboard upsert HTTP request per node on Port 0. It is not a domain in the `BaseDomain` sense: no measurements, no tick loop, no children of its own, no parent registration.
|
||||
|
||||
## 2. Position in the platform
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
ps[pumpingStation<br/>Process Cell]:::pc -.child.register.-> dash
|
||||
mgc[machineGroupControl<br/>Unit]:::unit -.child.register.-> dash
|
||||
rm[rotatingMachine<br/>Equipment]:::equip -.child.register.-> dash
|
||||
meas[measurement<br/>Control Module]:::ctrl -.child.register.-> dash
|
||||
dash[dashboardAPI<br/>Utility]:::neutral -->|POST /api/dashboards/db| grafana[(Grafana<br/>HTTP API)]
|
||||
classDef pc fill:#0c99d9,color:#fff
|
||||
classDef unit fill:#50a8d9,color:#000
|
||||
classDef equip fill:#86bbdd,color:#000
|
||||
classDef ctrl fill:#a9daee,color:#000
|
||||
classDef neutral fill:#dddddd,color:#000
|
||||
```
|
||||
|
||||
dashboardAPI has **no S88 level** — it's a utility node. Dashed arrows = inbound `child.register` events; the solid arrow is the outbound HTTP upsert on Port 0 to a downstream `http request` node.
|
||||
|
||||
## 3. Capability matrix
|
||||
|
||||
| Capability | Status | Notes |
|
||||
|---|---|---|
|
||||
| Accept `child.register` from any EVOLV node | ✅ | Resolves via `RED.nodes.getNode` → `node._flow.getNode` → inline payload. |
|
||||
| Emit Grafana dashboard upsert (Port 0) | ✅ | One msg per generated dashboard, shaped for `http request`. |
|
||||
| Walk child graph + emit per-child dashboards | ✅ | `includeChildren: true` by default; opt-out per call. |
|
||||
| Add root → child dashboard `links[]` | ✅ | Each direct child appears as a link on the root dashboard. |
|
||||
| Template selection by `softwareType` | ✅ | Reads from `config/<softwareType>.json`; case-insensitive fallback. |
|
||||
| Bearer-token auth | ✅ | Set via editor or `INFLUXDB_BUCKET` env (bucket only). |
|
||||
| Domain output on Port 0 | ❌ | Never. Port 0 carries HTTP request envelopes, not measurements. |
|
||||
| Port 1 telemetry / Port 2 registration | ❌ | Both unused — see Section 8. |
|
||||
| Status badge / tick loop / FSM | ❌ | Stateless; no periodic emission. |
|
||||
|
||||
## 4. Code map
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph nodeRED["nodeClass.js — passive adapter"]
|
||||
nc["createRegistry(commands)<br/>dispatch on msg input<br/>NO BaseNodeAdapter"]
|
||||
end
|
||||
subgraph domain["specificClass.js — DashboardApi service"]
|
||||
sc["buildDashboard()<br/>generateDashboardsForGraph()<br/>extractChildren()"]
|
||||
end
|
||||
subgraph cmd["src/commands/"]
|
||||
h["child.register handler<br/>+ registerChild alias"]
|
||||
end
|
||||
subgraph tpl["config/ (templates)"]
|
||||
t["<softwareType>.json<br/>Grafana JSON skeletons"]
|
||||
end
|
||||
nc --> sc
|
||||
nc --> cmd
|
||||
sc --> tpl
|
||||
```
|
||||
|
||||
| Module | Owns | Read first if you're changing… |
|
||||
|---|---|---|
|
||||
| `src/nodeClass.js` | Input wiring, command dispatch, config build | Topic dispatching, configuration mapping. |
|
||||
| `src/specificClass.js` | Template loading, dashboard composition, child graph walk | UID stability, links, templating-var substitution. |
|
||||
| `src/commands/` | `child.register` handler + legacy alias | Adding / renaming inbound topics. |
|
||||
| `config/` | Per-softwareType Grafana templates | Adding support for new node types. |
|
||||
|
||||
dashboardAPI deliberately does NOT split into `concerns/` — its surface is too narrow. See CONTRACT.md → "Why no BaseNodeAdapter / BaseDomain" for the rationale.
|
||||
|
||||
## 5. Topic contract
|
||||
|
||||
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
|
||||
|
||||
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||
|
||||
| Canonical topic | Aliases | Payload | Effect |
|
||||
|---|---|---|---|
|
||||
| `child.register` | `registerChild` | `any` | Parent/child plumbing — registers or unregisters a child node. |
|
||||
|
||||
<!-- END AUTOGEN: topic-contract -->
|
||||
|
||||
The legacy `registerChild` alias logs a one-time deprecation warning on first use. The payload can be a string (child id), `{ source: {...} }`, or `{ config: {...} }`; `msg.includeChildren` (default `true`) controls graph-walk depth.
|
||||
|
||||
## 6. Child registration
|
||||
|
||||
dashboardAPI does **not** maintain a child registry of its own. Instead, every inbound `child.register` triggers a one-shot resolution + dashboard emission. No state is held between calls.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
src["any EVOLV node<br/>(has functionality.softwareType)"]:::other -->|child.register| dash[dashboardAPI<br/>Utility]:::neutral
|
||||
dash --> resolve[resolve child source<br/>RED.nodes.getNode → _flow → inline]
|
||||
resolve --> walk["source.generateDashboardsForGraph(child)<br/>(includes children if flag set)"]
|
||||
walk --> emit[emit one msg per dashboard<br/>topic='create']
|
||||
emit --> http[(downstream<br/>http request node)]
|
||||
classDef neutral fill:#dddddd,color:#000
|
||||
classDef other fill:#ffffff,stroke:#666
|
||||
```
|
||||
|
||||
| Inbound softwareType | Filter | Side effect |
|
||||
|---|---|---|
|
||||
| any | child has `functionality.softwareType` | Loads `config/<softwareType>.json`; emits one upsert msg per dashboard in the graph walk. |
|
||||
| (template missing) | no matching `config/*.json` | Warns and skips that dashboard. No error. |
|
||||
|
||||
## 7. Lifecycle — what one event does
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant emitter as any EVOLV node
|
||||
participant dash as dashboardAPI
|
||||
participant api as DashboardApi
|
||||
participant out as Port-0 output
|
||||
participant grafana as Grafana HTTP API
|
||||
|
||||
emitter->>dash: child.register {source / config / id}
|
||||
dash->>dash: commandRegistry dispatch
|
||||
dash->>api: generateDashboardsForGraph(child, {includeChildren})
|
||||
api->>api: loadTemplate(softwareType)
|
||||
api->>api: stableUid + updateTemplatingVar
|
||||
api->>api: walk children via childRegistrationUtils
|
||||
api-->>dash: [{dashboard, uid, title}, ...]
|
||||
loop per dashboard
|
||||
dash->>out: msg{topic:'create', url, method, headers, payload}
|
||||
out->>grafana: POST /api/dashboards/db
|
||||
end
|
||||
```
|
||||
|
||||
One inbound event yields N outbound HTTP messages (N = 1 + direct child count when `includeChildren=true`).
|
||||
|
||||
## 8. Data model — `getOutput()`
|
||||
|
||||
> **dashboardAPI has no domain output.** It does not extend `BaseDomain` and does not implement `getOutput()`. The `wiki:datamodel` script falls back to the placeholder below.
|
||||
|
||||
<!-- BEGIN AUTOGEN: data-model -->
|
||||
|
||||
No domain output. dashboardAPI emits **HTTP request envelopes on Port 0**, shaped for a downstream `http request` node:
|
||||
|
||||
```js
|
||||
{
|
||||
topic: 'create',
|
||||
url: 'http://<grafana>:<port>/api/dashboards/db',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer …' // only when bearerToken is set
|
||||
},
|
||||
payload: { dashboard: {…}, folderId: 0, overwrite: true },
|
||||
meta: { nodeId, softwareType, uid, title }
|
||||
}
|
||||
```
|
||||
|
||||
Port 1 (InfluxDB telemetry) and Port 2 (registration / control plumbing) are unused — dashboardAPI has no measurements and does not register with a parent.
|
||||
|
||||
<!-- END AUTOGEN: data-model -->
|
||||
|
||||
See CONTRACT.md for the full envelope spec.
|
||||
|
||||
## 9. Configuration — editor form ↔ config keys
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph editor["Node-RED editor form"]
|
||||
f1[Protocol]
|
||||
f2[Host]
|
||||
f3[Port]
|
||||
f4[Bearer token]
|
||||
f5[Default bucket]
|
||||
end
|
||||
subgraph config["Domain config slice"]
|
||||
c1[grafanaConnector.protocol]
|
||||
c2[grafanaConnector.host]
|
||||
c3[grafanaConnector.port]
|
||||
c4[grafanaConnector.bearerToken]
|
||||
c5[defaultBucket]
|
||||
end
|
||||
f1 --> c1
|
||||
f2 --> c2
|
||||
f3 --> c3
|
||||
f4 --> c4
|
||||
f5 --> c5
|
||||
```
|
||||
|
||||
| Form field | Config key | Default | Range | Where used |
|
||||
|---|---|---|---|---|
|
||||
| Protocol | `grafanaConnector.protocol` | `http` | `http`\|`https` | `grafanaUpsertUrl()` |
|
||||
| Host | `grafanaConnector.host` | `localhost` | hostname | `grafanaUpsertUrl()` |
|
||||
| Port | `grafanaConnector.port` | `3000` | 1–65535 | `grafanaUpsertUrl()` |
|
||||
| Bearer token | `grafanaConnector.bearerToken` | `''` | string | `Authorization` header |
|
||||
| Default bucket | `defaultBucket` | `''` / `INFLUXDB_BUCKET` env | string | `updateTemplatingVar(bucket)` |
|
||||
|
||||
## 11. Examples
|
||||
|
||||
| Tier | File | What it shows | Status |
|
||||
|---|---|---|---|
|
||||
| Basic | `examples/01-Basic.flow.json` | Inject `child.register` payload + a downstream `http request` → mock Grafana | ⏳ TBD |
|
||||
| Integration | `examples/02-Integration.flow.json` | Real EVOLV node (e.g. pumpingStation) wired into dashboardAPI | ⏳ TBD |
|
||||
| Dashboard | _n/a_ | dashboardAPI **is** the dashboard plumbing — no FlowFuse tier | — |
|
||||
|
||||
## 12. Debug recipes
|
||||
|
||||
| Symptom | First thing to check | Where to look |
|
||||
|---|---|---|
|
||||
| No HTTP request emitted | Did the `child.register` resolve a source? `source.generateDashboardsForGraph` returns `[]` when child has no `config`. | `node_redlog` for "generateDashboardsForGraph skipped" warning. |
|
||||
| `Skipping dashboard generation: no template` | `config/<softwareType>.json` missing for this node type. | `config/` directory; add a template. |
|
||||
| Empty `Authorization` header | `bearerToken` not set in editor. | Editor form → Bearer token field. |
|
||||
| Wrong bucket in Grafana | `defaultBucket` overrides position-based default. Check `INFLUXDB_BUCKET` env. | `_buildConfig` in nodeClass.js. |
|
||||
| `registerChild` alias warns once | Expected — migrate callers to `child.register`. | Caller's `msg.topic`. |
|
||||
|
||||
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors.
|
||||
|
||||
## 13. When you would NOT use this node
|
||||
|
||||
- Use dashboardAPI when you want **auto-generated Grafana dashboards** from EVOLV node topology. If you maintain dashboards by hand in Grafana, skip it.
|
||||
- Don't use dashboardAPI as a generic HTTP client — it only emits Grafana dashboard upserts. For arbitrary HTTP, use a plain `http request` node.
|
||||
- Don't put dashboardAPI in a hot data path. It fires on registration events, not on each tick — wiring tick data to it is a misuse.
|
||||
|
||||
## 14. Known limitations / current issues
|
||||
|
||||
| # | Issue | Tracked in |
|
||||
|---|---|---|
|
||||
| 1 | No domain output — cannot be introspected through the standard `getOutput()` channel; debugging relies on Port 0 HTTP envelopes. | CONTRACT.md → "Why no BaseNodeAdapter / BaseDomain" |
|
||||
| 2 | Template discovery is filename-based; renaming a node's `softwareType` requires renaming the template file (with the `machineGroupControl → machineGroup.json` alias being a one-off). | `_templateFileForSoftwareType` in specificClass.js |
|
||||
| 3 | No retry / circuit-breaker on the downstream `http request` — Grafana outages drop dashboards silently. | TBD |
|
||||
| 4 | Tier 1/2 example flows not yet written. | P9 wiki cleanup follow-up |
|
||||
18
wiki/_partial-datamodel.md.template
Normal file
18
wiki/_partial-datamodel.md.template
Normal file
@@ -0,0 +1,18 @@
|
||||
No domain output. dashboardAPI emits **HTTP request envelopes on Port 0**, shaped for a downstream `http request` node:
|
||||
|
||||
```js
|
||||
{
|
||||
topic: 'create',
|
||||
url: 'http://<grafana>:<port>/api/dashboards/db',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer …' // only when bearerToken is set
|
||||
},
|
||||
payload: { dashboard: {…}, folderId: 0, overwrite: true },
|
||||
meta: { nodeId, softwareType, uid, title }
|
||||
}
|
||||
```
|
||||
|
||||
Port 1 (InfluxDB telemetry) and Port 2 (registration / control plumbing) are unused — dashboardAPI has no measurements and does not register with a parent.
|
||||
Reference in New Issue
Block a user