From c5272fcc2494ed08bce1bcda062d2ac7df932ad6 Mon Sep 17 00:00:00 2001 From: Rene De Ren Date: Thu, 12 Mar 2026 16:43:29 +0100 Subject: [PATCH] Adopt buildConfig in dashboardapi adapter --- src/nodeClass.js | 144 +++++++++++++++++++------------------- test/nodeClass.test.js | 153 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 72 deletions(-) create mode 100644 test/nodeClass.test.js diff --git a/src/nodeClass.js b/src/nodeClass.js index ad86daa..6251bbe 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -1,17 +1,11 @@ -const { outputUtils } = require('generalFunctions'); -const Specific = require('./specificClass'); +const { configManager } = require('generalFunctions'); +const DashboardApi = require('./specificClass'); -/** - * Node-RED wrapper for dashboard generation requests. - * It listens for `registerChild` messages and emits Grafana upsert requests. - */ 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(); @@ -20,92 +14,98 @@ class nodeClass { } _loadConfig(uiConfig) { - this.config = { - general: { - name: uiConfig.name || this.name, - logging: { - enabled: uiConfig.enableLog, - logLevel: uiConfig.logLevel || 'info', - }, + const cfgMgr = new configManager(); + this.config = cfgMgr.buildConfig(this.name, uiConfig, this.node.id, { + functionality: { + softwareType: this.name.toLowerCase(), + role: 'auto ui generator', }, grafanaConnector: { - protocol: uiConfig.protocol || 'http', host: uiConfig.host || 'localhost', - port: Number(uiConfig.port || 3000), - bearerToken: uiConfig.bearerToken || '', + port: Number(uiConfig.port) || 3000, + protocol: uiConfig.protocol || 'http', + bearerToken: uiConfig.bearerToken || null, }, - defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '', - }; - - this._output = new outputUtils(); + }); } _setupSpecificClass() { - this.source = new Specific(this.config); + 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; + } + + _resolveChildConfig(payload) { + if (payload?.source?.config) { + return payload.source.config; + } + + if (payload?.config) { + return payload.config; + } + + if (typeof payload === 'string') { + return this._resolveChildNode(payload)?.source?.config || null; + } + + return null; + } + _attachInputHandler() { this.node.on('input', async (msg, send, done) => { try { - if (msg.topic !== 'registerChild') { - if (typeof done === 'function') done(); - return; + switch (msg.topic) { + case 'registerChild': { + const childConfig = this._resolveChildConfig(msg.payload); + if (!childConfig) { + throw new Error('Missing or invalid child node'); + } + + const payload = await this.source.generateDashB(childConfig); + const authToken = process.env.GRAFANA_TOKEN || this.config.grafanaConnector.bearerToken || ''; + + send({ + ...msg, + topic: 'create', + payload, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + }); + break; + } + default: + this.source.logger.warn(`Unknown topic: ${msg.topic}`); + break; } - const childId = msg.payload; - const childObj = this.RED.nodes.getNode(childId); - const childSource = childObj?.source; - if (!childSource?.config) { - this.node.warn(`registerChild skipped: missing child source/config for id=${childId}`); - if (typeof done === 'function') done(); - return; - } - - // Generate one dashboard for the root source and optionally its registered children. - const dashboards = this.source.generateDashboardsForGraph(childSource, { - includeChildren: Boolean(msg.includeChildren ?? true), - }); - - 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) { - // Forward dashboard definitions to an HTTP request node configured for Grafana API. - const payload = this.source.buildUpsertRequest({ dashboard: dash.dashboard, folderId: 0, overwrite: true }); - send({ - topic: 'grafana.dashboard.upsert', - url, - method: 'POST', - headers, - payload, - meta: { - nodeId: dash.nodeId, - softwareType: dash.softwareType, - uid: dash.uid, - title: dash.title, - }, - }); - } - - if (typeof done === 'function') done(); + done(); } catch (error) { - this.node.status({ fill: 'red', shape: 'ring', text: 'dashboardapi error' }); - this.node.error(error?.message || error, msg); - if (typeof done === 'function') done(); + this.node.status({ fill: 'red', shape: 'ring', text: 'Bad request data' }); + this.node.error(`Bad request data: ${error.message}`, msg); + done(error); } }); } _attachCloseHandler() { this.node.on('close', (done) => { - if (typeof done === 'function') done(); + done(); }); } } diff --git a/test/nodeClass.test.js b/test/nodeClass.test.js new file mode 100644 index 0000000..5934752 --- /dev/null +++ b/test/nodeClass.test.js @@ -0,0 +1,153 @@ +const NodeClass = require('../src/nodeClass'); + +jest.mock('../src/specificClass', () => { + return jest.fn().mockImplementation(() => ({ + logger: { + warn: jest.fn(), + }, + generateDashB: jest.fn().mockResolvedValue({ dashboard: { title: 'ok' } }), + })); +}); + +const SpecificClass = require('../src/specificClass'); + +function createNodeHarness(flowNode = null) { + const handlers = {}; + const node = { + id: 'dashboard-node-id', + on: jest.fn((event, handler) => { + handlers[event] = handler; + }), + status: jest.fn(), + error: jest.fn(), + _flow: { + getNode: jest.fn(() => flowNode), + }, + }; + + return { node, handlers }; +} + +describe('dashboardAPI nodeClass', () => { + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.GRAFANA_TOKEN; + }); + + it('uses RED.nodes.getNode when it returns a runtime child', async () => { + const childNode = { + source: { + config: { + general: { name: 'child' }, + functionality: { softwareType: 'measurement' }, + }, + }, + }; + const { node, handlers } = createNodeHarness(); + const RED = { + nodes: { + getNode: jest.fn(() => childNode), + }, + }; + + new NodeClass({ name: 'E2E-DashboardAPI', host: 'grafana', port: 3000 }, RED, node, 'dashboardapi'); + + expect(SpecificClass).toHaveBeenCalledWith( + expect.objectContaining({ + general: expect.objectContaining({ + name: 'E2E-DashboardAPI', + id: 'dashboard-node-id', + }), + functionality: expect.objectContaining({ + softwareType: 'dashboardapi', + role: 'auto ui generator', + }), + grafanaConnector: expect.objectContaining({ + host: 'grafana', + port: 3000, + }), + }), + ); + + const send = jest.fn(); + const done = jest.fn(); + await handlers.input({ topic: 'registerChild', payload: 'measurement-e2e-node' }, send, done); + + expect(RED.nodes.getNode).toHaveBeenCalledWith('measurement-e2e-node'); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + topic: 'create', + payload: { dashboard: { title: 'ok' } }, + }), + ); + expect(done).toHaveBeenCalledWith(); + }); + + it('falls back to the active flow when RED.nodes.getNode lacks source state', async () => { + const flowChildNode = { + source: { + config: { + general: { name: 'child' }, + functionality: { softwareType: 'measurement' }, + }, + }, + }; + const { node, handlers } = createNodeHarness(flowChildNode); + const RED = { + nodes: { + getNode: jest.fn(() => ({ id: 'measurement-e2e-node' })), + }, + }; + + new NodeClass({ name: 'E2E-DashboardAPI', host: 'grafana', port: 3000 }, RED, node, 'dashboardapi'); + + const send = jest.fn(); + const done = jest.fn(); + await handlers.input({ topic: 'registerChild', payload: 'measurement-e2e-node' }, send, done); + + expect(node._flow.getNode).toHaveBeenCalledWith('measurement-e2e-node'); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + topic: 'create', + payload: { dashboard: { title: 'ok' } }, + }), + ); + expect(done).toHaveBeenCalledWith(); + }); + + it('accepts a child config payload directly', async () => { + const { node, handlers } = createNodeHarness(); + const RED = { + nodes: { + getNode: jest.fn(), + }, + }; + + new NodeClass({ name: 'E2E-DashboardAPI', host: 'grafana', port: 3000 }, RED, node, 'dashboardapi'); + + const send = jest.fn(); + const done = jest.fn(); + await handlers.input( + { + topic: 'registerChild', + payload: { + config: { + general: { name: 'E2E-Level-Sensor' }, + functionality: { softwareType: 'measurement' }, + }, + }, + }, + send, + done, + ); + + expect(RED.nodes.getNode).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + topic: 'create', + payload: { dashboard: { title: 'ok' } }, + }), + ); + expect(done).toHaveBeenCalledWith(); + }); +});