Adopt buildConfig in dashboardapi adapter

This commit is contained in:
Rene De Ren
2026-03-12 16:43:29 +01:00
parent 89d2260351
commit c5272fcc24
2 changed files with 225 additions and 72 deletions

View File

@@ -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 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;
}
const payload = await this.source.generateDashB(childConfig);
const authToken = process.env.GRAFANA_TOKEN || this.config.grafanaConnector.bearerToken || '';
// 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 = {
send({
...msg,
topic: 'create',
payload,
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,
Authorization: `Bearer ${authToken}`,
},
});
break;
}
default:
this.source.logger.warn(`Unknown topic: ${msg.topic}`);
break;
}
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();
});
}
}

153
test/nodeClass.test.js Normal file
View File

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