diff --git a/CONTRACT.md b/CONTRACT.md new file mode 100644 index 0000000..8ace365 --- /dev/null +++ b/CONTRACT.md @@ -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: , // 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/.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. diff --git a/src/commands/handlers.js b/src/commands/handlers.js new file mode 100644 index 0000000..e735997 --- /dev/null +++ b/src/commands/handlers.js @@ -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 }; diff --git a/src/commands/index.js b/src/commands/index.js new file mode 100644 index 0000000..5bef2e6 --- /dev/null +++ b/src/commands/index.js @@ -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, + }, +]; diff --git a/src/nodeClass.js b/src/nodeClass.js index 62766f4..85a3861 100644 --- a/src/nodeClass.js +++ b/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' }); diff --git a/test/basic/structure-module-load.basic.test.js b/test/basic/structure-module-load.basic.test.js index 766c755..dde1c59 100644 --- a/test/basic/structure-module-load.basic.test.js +++ b/test/basic/structure-module-load.basic.test.js @@ -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'); }); });