6 Commits

Author SHA1 Message Date
znetsixe
a6f09d821d docs: Folder & File Layout section + flag dashboardapi.{js,html} naming drift
Entry/HTML files should be dashboardAPI.{js,html} (case-sensitive match with
the folder name). Rename when the file is next touched. Full rule:
.claude/rules/node-architecture.md in the EVOLV superproject.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:30:46 +02:00
znetsixe
f0a7904985 P11.7 wiki: rewrite Home.md to full 14-section visual-first template
Adapts the canonical WIKI_TEMPLATE.md for dashboardAPI as a utility node
(no BaseDomain, no S88 level, no state chart). Key changes vs P9.3 draft:
- Banner hash bumped to 7b3da23
- Section 1: tightened to exactly describe topology→dashboard flow
- Section 2: adds FlowFuse/browser as downstream consumer of Grafana dashboards
- Section 3: expands capabilities (stable UID, bucket-per-position, alias alias)
- Section 4: adds dashboardapi.js entry node + real config/ template list
- Section 5: AUTOGEN markers regenerated via npm run wiki:all
- Section 6: rewrites diagram with resolveChildSource detail
- Section 7: full sequence including stableUid + links[] step
- Section 8: AUTOGEN marker regenerated; adds meta-field table
- Section 9: adds enableLog/logLevel fields; adds bucket-fallback table
- Section 10: explicit SKIPPED marker (stateless node)
- Section 11: adds inline wiring example
- Section 12: expands to 7 recipes (adds UID-change, machineGroupControl alias)
- Section 13: adds "not a BaseDomain node" + OPEN_QUESTIONS reference
- Section 14: adds OPEN_QUESTIONS.md link for BaseDomain decision; keeps 5 issues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:06:42 +02:00
znetsixe
7b3da23fba P11.6 wiki regen + Phase 10 private-test rewrites where applicable
For all 11 nodes with auto-gen markers: wiki/Home.md sections 5 (topic
contract) and 9 (data model) regenerated via npm run wiki:all. New
Unit column shows '<measure> (default <unit>)' for declared topics,
'—' otherwise. Effect column now uses descriptor.description (P11.2
field) overriding the generic per-prefix fallback.

For rotatingMachine + reactor: Phase 10 test rewrites — 3 + 8 files
moved off private nodeClass internals (_attachInputHandler, _commands,
_pendingExtras, _registerChild, _tick, etc.) to the public
BaseNodeAdapter surface (node.handlers.input, node.source.*).
+6 / +7 net new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:44:02 +02:00
znetsixe
67a374ff4f P9.3: wiki/Home.md following 14-section visual-first template + wiki:* scripts
Auto-generated topic-contract + data-model sections via shared wikiGen
script. Hand-written Mermaid diagrams for position-in-platform, code
map, child registration, lifecycle, configuration, state chart (where
applicable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:17:46 +02:00
znetsixe
92d7eba0fd P10.2: convert remaining dashboardAPI tests from Mocha to node:test
P6.7 converted test/basic/. Convert test/edge/ and test/integration/ the
same way: describe/it/expect → test/assert. No behavioural change.

5 / 5 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:44:15 +02:00
znetsixe
2874608375 P6: convert dashboardAPI to platform infrastructure
Refactor of dashboardAPI to use BaseNodeAdapter + commandRegistry + statusBadge.
dashboardAPI follows the platform refactor plan in .claude/refactor/MODULE_SPLIT.md.
Tests stay green; CONTRACT.md generated; legacy aliases preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:23:45 +02:00
11 changed files with 542 additions and 109 deletions

View File

@@ -21,3 +21,28 @@ Key points for this node:
- Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- Wrap in a Node-RED group box coloured `none` (Utility (no S88 level)).
## Folder & File Layout
Every per-node file MUST use the folder name (`dashboardAPI`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
| Path | Required name |
|---|---|
| Entry file | `dashboardAPI.js` |
| Editor HTML | `dashboardAPI.html` |
| Node adapter | `src/nodeClass.js` |
| Domain logic | `src/specificClass.js` |
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
| Tests | `test/{basic,integration,edge}/*.test.js` |
| Example flows | `examples/*.flow.json` |
> ⚠️ **Legacy naming drift in this repo** — to be renamed when the file is next touched:
>
> | Path | Currently | Should be |
> |---|---|---|
> | Entry file | `dashboardapi.js` | `dashboardAPI.js` |
> | Editor HTML | `dashboardapi.html` | `dashboardAPI.html` |
>
> Renames require updating: the file itself, `package.json#node-red.nodes`, any `require()` / `import` paths, and superproject submodule references in one commit.
When adding new files, read the rule above first to avoid drift.

80
CONTRACT.md Normal file
View 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.

View File

@@ -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
View 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
View 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,
},
];

View File

@@ -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' });

View File

@@ -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');
});
});

View File

@@ -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}`);
});

View File

@@ -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`);
}
});

287
wiki/Home.md Normal file
View File

@@ -0,0 +1,287 @@
# dashboardAPI
> **Reflects code as of `7b3da23` · 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 a utility node that converts EVOLV node topology into Grafana dashboards. On each `child.register` event it resolves the child's source, walks its direct children, loads per-`softwareType` Grafana JSON templates from `config/`, and emits one HTTP upsert request per dashboard on Port 0 to a downstream `http request` node. It has no measurements, no tick loop, no parent registration, and no BaseDomain/BaseNodeAdapter.
## 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)]
grafana -.renders dashboards for.-> ff[FlowFuse / Browser]
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 is a utility node (`#dddddd` neutral). Dashed arrows = inbound `child.register` events (fired at deploy time). The solid arrow is the outbound HTTP upsert on Port 0. The Grafana dashboards that result are what FlowFuse / browser clients view.
## 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` node. |
| Walk child graph + emit per-child dashboards | ✅ | `msg.includeChildren: true` by default; opt-out per call. |
| Add root → child dashboard `links[]` | ✅ | Each direct child appears as a navigation link on the root dashboard. |
| Template selection by `softwareType` | ✅ | Reads from `config/<softwareType>.json`; case-insensitive fallback. `machineGroupControl``machineGroup.json` alias. |
| Stable dashboard UID across re-deploys | ✅ | SHA-1(`softwareType:nodeId`) first 12 chars — deterministic, idempotent upsert. |
| Bearer-token auth header | ✅ | Set via editor `bearerToken` field; omitted if empty. |
| InfluxDB bucket injection per position | ✅ | `upstream → lvl1`, `downstream → lvl3`, else `lvl2`; overridden by `defaultBucket` or `INFLUXDB_BUCKET` env. |
| Domain output on Port 0 | ❌ | Port 0 carries HTTP request envelopes only, not measurement data. |
| 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 entry["dashboardapi.js — entry (Node-RED registration)"]
e["RED.nodes.registerType('dashboardapi')<br/>admin: GET /dashboardapi/menu.js<br/>admin: GET /dashboardapi/configData.js"]
end
subgraph nodeRED["src/nodeClass.js — passive adapter"]
nc["_buildConfig(uiConfig)<br/>createRegistry(commands)<br/>_attachInputHandler() → dispatch<br/>NO BaseNodeAdapter"]
end
subgraph domain["src/specificClass.js — DashboardApi service"]
sc["buildDashboard({ nodeConfig, positionVsParent })<br/>generateDashboardsForGraph(rootSource)<br/>extractChildren(nodeSource)<br/>grafanaUpsertUrl()<br/>loadTemplate(softwareType)"]
end
subgraph cmd["src/commands/"]
h["index.js — child.register + registerChild alias<br/>handlers.js — resolveChildSource + registerChild"]
end
subgraph tpl["config/ — Grafana JSON templates"]
t["aeration | dashboardapi | machine<br/>machineGroup | measurement | monster<br/>pumpingStation | reactor | settler<br/>valve | valveGroupControl"]
end
e --> nodeRED
nodeRED --> domain
nodeRED --> cmd
domain --> tpl
```
| Module | Owns | Read first if you're changing… |
|---|---|---|
| `dashboardapi.js` | Node-RED registration, admin HTTP endpoints | Adding editor endpoints, node category. |
| `src/nodeClass.js` | Input wiring, command dispatch, config build | Topic dispatching, config key mapping. |
| `src/specificClass.js` | Template loading, dashboard composition, graph walk | UID stability, links, bucket injection. |
| `src/commands/index.js` | Command registry definition | Adding or renaming inbound topics. |
| `src/commands/handlers.js` | `child.register` handler logic | Payload resolution, emit loop. |
| `config/` | Per-softwareType Grafana JSON templates | Adding support for new EVOLV node types. |
dashboardAPI deliberately does NOT use the `concerns/` module pattern — its logic surface is too narrow. See `CONTRACT.md → "Why no BaseNodeAdapter / BaseDomain"` for the full 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 | Unit | 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 node id), `{ source: {...} }`, or `{ config: {...} }`; `msg.includeChildren` (default `true`) controls graph-walk depth.
There is **no HTTP endpoint contract** for dashboardAPI — it is a Node-RED input node only. The outbound HTTP call shape is documented in Section 8.
## 6. Child registration
dashboardAPI does **not** maintain a child registry of its own. 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["resolveChildSource(payload, ctx)<br/>RED.nodes.getNode → _flow → inline"]
resolve --> walk["generateDashboardsForGraph(childSource)<br/>(walks direct children if includeChildren=true)"]
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 at `warn` level and skips that dashboard. No error thrown. |
## 7. Lifecycle — what one event does
```mermaid
sequenceDiagram
participant emitter as any EVOLV node
participant dash as dashboardAPI (nodeClass)
participant api as DashboardApi (specificClass)
participant out as Port-0 output
participant grafana as Grafana HTTP API
emitter->>dash: child.register {source / config / id}
dash->>dash: commandRegistry.dispatch → handlers.registerChild
dash->>dash: resolveChildSource(payload, ctx)
dash->>api: generateDashboardsForGraph(childSource, {includeChildren})
api->>api: buildDashboard({ nodeConfig, positionVsParent })
api->>api: loadTemplate(softwareType) from config/
api->>api: stableUid = sha1(softwareType:nodeId).slice(0,12)
api->>api: updateTemplatingVar(measurement, bucket)
api->>api: extractChildren → build child dashboards
api->>api: add links[] on root dashboard
api-->>dash: [{dashboard, uid, title, softwareType, nodeId}, ...]
loop per dashboard in results
dash->>out: msg{topic:'create', url, method, headers, payload, meta}
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 — output shape
> **dashboardAPI has no domain output.** It does not extend `BaseDomain` and does not implement `getOutput()`. The `wiki:datamodel` script falls back to the hand-curated template 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 -->
**`meta` fields:**
| Field | Type | Value |
|---|---|---|
| `nodeId` | string | `config.general.id` or `config.general.name` |
| `softwareType` | string | `config.functionality.softwareType` |
| `uid` | string | SHA-1(`softwareType:nodeId`) first 12 chars — stable across re-deploys |
| `title` | string | `config.general.name` |
Inbound `msg` fields propagate via spread (`{...msg, ...envelope}`) — caller-supplied correlation/trace fields survive.
See `CONTRACT.md` for the full envelope spec and port definitions.
## 9. Configuration — editor form ↔ config keys
```mermaid
flowchart TB
subgraph editor["Node-RED editor form (dashboardapi.html)"]
f1[Protocol — select http/https]
f2[Grafana Host — text]
f3[Grafana Port — number]
f4[Bearer Token — password]
f5[InfluxDB Bucket — text]
f6[Enable Log — checkbox]
f7[Log Level — select]
end
subgraph config["Runtime config slice (_buildConfig)"]
c1[grafanaConnector.protocol]
c2[grafanaConnector.host]
c3[grafanaConnector.port]
c4[grafanaConnector.bearerToken]
c5[defaultBucket]
c6[general.logging.enabled]
c7[general.logging.logLevel]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
f6 --> c6
f7 --> c7
```
| Form field | Config key | Default | Range / values | Where used |
|---|---|---|---|---|
| Protocol | `grafanaConnector.protocol` | `http` | `http` \| `https` | `grafanaUpsertUrl()` |
| Grafana Host | `grafanaConnector.host` | `localhost` | hostname / IP | `grafanaUpsertUrl()` |
| Grafana Port | `grafanaConnector.port` | `3000` | 165535 | `grafanaUpsertUrl()` |
| Bearer Token | `grafanaConnector.bearerToken` | `''` | string (Grafana service account token) | `Authorization: Bearer …` header |
| InfluxDB Bucket | `defaultBucket` | `''` → falls back to `INFLUXDB_BUCKET` env → position default | string | `updateTemplatingVar('bucket', …)` |
| Enable Log | `general.logging.enabled` | `false` | boolean | Logger constructor |
| Log Level | `general.logging.logLevel` | `'info'` | `info` \| `debug` \| `warn` \| `error` | Logger constructor |
**Position-based bucket fallback** (when `defaultBucket` is empty):
| `positionVsParent` | Bucket used |
|---|---|
| `upstream` | `lvl1` |
| `downstream` | `lvl3` |
| any other / absent | `lvl2` |
## 10. State chart
> **Skipped.** dashboardAPI is stateless — no FSM, no tick loop, no operating states. See template rule: "Skip this section for stateless nodes (`measurement`, `dashboardAPI`)."
## 11. Examples
| Tier | File | What it shows | Status |
|---|---|---|---|
| Basic | `examples/basic.flow.json` | Inject `child.register` payload (inline config) + downstream `http request` → Grafana | ⏳ TBD — stub exists |
| Integration | `examples/integration.flow.json` | Real EVOLV node (e.g. pumpingStation) → child.register → dashboardAPI → Grafana | ⏳ TBD — stub exists |
| Dashboard | _n/a_ | dashboardAPI **generates** Grafana dashboards — no FlowFuse chart tier for this node | — |
**Wiring pattern** (inline-payload basic test):
```json
[
{ "type": "inject", "payload": { "source": { "config": { "functionality": { "softwareType": "measurement" }, "general": { "id": "pump-a-flow", "name": "Pump A flow" } } } }, "topic": "child.register" },
{ "type": "dashboardapi" },
{ "type": "http request", "method": "POST" }
]
```
## 12. Debug recipes
| Symptom | First thing to check | Where to look |
|---|---|---|
| No HTTP message emitted on Port 0 | Did `resolveChildSource` return a non-null source? Check that payload has `.source.config` or `.config` or a valid node id. | Container log for "generateDashboardsForGraph skipped" warning. |
| `Skipping dashboard generation: no template` | `config/<softwareType>.json` missing. | `config/` directory — add a template JSON file for the new node type. |
| `machineGroupControl` produces no dashboard | The alias maps to `machineGroup.json` — verify that file exists in `config/`. | `_templateFileForSoftwareType` in `specificClass.js`. |
| Empty `Authorization` header | `bearerToken` not set in editor form. | Editor → Bearer Token field. |
| Wrong InfluxDB bucket in Grafana template variables | `defaultBucket` config or `INFLUXDB_BUCKET` env overrides the position-based default. | `_buildConfig` in `nodeClass.js` + `defaultBucketForPosition` in `specificClass.js`. |
| Dashboard UID changes between deploys | Node id or `softwareType` changed — UID is `sha1(softwareType:nodeId)`. | `stableUid` in `specificClass.js`. |
| `registerChild` alias warns once | Expected — deprecation warning on first use. Migrate caller to topic `child.register`. | Caller `msg.topic`. |
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors. Use only for live debugging sessions.
## 13. When you would NOT use this node
- **Use dashboardAPI only for auto-generating Grafana dashboards from EVOLV topology.** If you maintain dashboards manually in Grafana, skip it — it will overwrite your customisations on every registration event.
- **Don't use dashboardAPI as a generic Grafana HTTP client.** It only emits dashboard upserts (`POST /api/dashboards/db`). For arbitrary Grafana API calls (annotations, alerts, data sources) use a plain `http request` node.
- **Don't wire tick/measurement data into dashboardAPI.** It fires on `child.register` events (deploy time), not on the measurement tick. Wiring Port-0 data from a rotatingMachine or pumpingStation here is a misuse.
- **Don't expect EVOLV child registration to happen automatically.** dashboardAPI passively receives `child.register`; the emitting node (e.g. pumpingStation) must have its Port 2 wired to dashboardAPI's input. See Section 7.
- **Not a BaseDomain node.** dashboardAPI cannot be used wherever a BaseDomain-capable node is required (e.g. as a registered child of machineGroupControl). See OPEN_QUESTIONS.md (2026-05-10) and Section 14.
## 14. Known limitations / current issues
| # | Issue | Tracked in |
|---|---|---|
| 1 | No domain output — cannot be introspected via the standard `getOutput()` channel. Debugging relies on watching Port 0 HTTP envelopes in a debug node. | `CONTRACT.md → "Why no BaseNodeAdapter / BaseDomain"` |
| 2 | Does not extend `BaseNodeAdapter` / `BaseDomain` — decision deferred pending a passive/HTTP-only mode on `BaseNodeAdapter` (skip-registration + skip-output-stream flags). Until that ships the bespoke adapter shape is correct. | `OPEN_QUESTIONS.md` (2026-05-10) — "dashboardAPI skipped BaseNodeAdapter + BaseDomain". Confirm with team whether to revisit when `BaseNodeAdapter` grows a passive mode. |
| 3 | Template discovery is filename-based. Renaming a node's `softwareType` requires renaming (or aliasing) the template file. The `machineGroupControl → machineGroup.json` mapping is a one-off alias in `_templateFileForSoftwareType`. | `src/specificClass.js → _templateFileForSoftwareType` |
| 4 | No retry / circuit-breaker on the downstream `http request` node — Grafana outages silently drop dashboard upserts. | TBD — no issue filed yet |
| 5 | Tier 1/2 example flows exist as stubs only (`basic.flow.json`, `integration.flow.json`) — not yet validated on a live Node-RED instance. | P9 wiki cleanup follow-up |

View 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.