Compare commits

...

2 Commits

Author SHA1 Message Date
Rene De Ren
66b91883ac Fix dashboardapi adapter and Jest coverage 2026-03-12 16:46:50 +01:00
Rene De Ren
c5272fcc24 Adopt buildConfig in dashboardapi adapter 2026-03-12 16:43:29 +01:00
6 changed files with 304 additions and 106 deletions

View File

@@ -1,10 +1,6 @@
const { outputUtils } = require('generalFunctions'); const { configManager } = require('generalFunctions');
const Specific = require('./specificClass'); const DashboardApi = require('./specificClass');
/**
* Node-RED wrapper for dashboard generation requests.
* It listens for `registerChild` messages and emits Grafana upsert requests.
*/
class nodeClass { class nodeClass {
constructor(uiConfig, RED, nodeInstance, nameOfNode) { constructor(uiConfig, RED, nodeInstance, nameOfNode) {
this.node = nodeInstance; this.node = nodeInstance;
@@ -20,13 +16,11 @@ class nodeClass {
} }
_loadConfig(uiConfig) { _loadConfig(uiConfig) {
this.config = { const cfgMgr = new configManager();
general: { this.config = cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
name: uiConfig.name || this.name, functionality: {
logging: { softwareType: this.name.toLowerCase(),
enabled: uiConfig.enableLog, role: 'auto ui generator',
logLevel: uiConfig.logLevel || 'info',
},
}, },
grafanaConnector: { grafanaConnector: {
protocol: uiConfig.protocol || 'http', protocol: uiConfig.protocol || 'http',
@@ -35,16 +29,44 @@ class nodeClass {
bearerToken: uiConfig.bearerToken || '', bearerToken: uiConfig.bearerToken || '',
}, },
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '', defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',
}; });
this._output = new outputUtils();
} }
_setupSpecificClass() { _setupSpecificClass() {
this.source = new Specific(this.config); this.source = new DashboardApi(this.config);
this.node.source = this.source; 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() { _attachInputHandler() {
this.node.on('input', async (msg, send, done) => { this.node.on('input', async (msg, send, done) => {
try { try {
@@ -53,16 +75,11 @@ class nodeClass {
return; return;
} }
const childId = msg.payload; const childSource = this._resolveChildSource(msg.payload);
const childObj = this.RED.nodes.getNode(childId);
const childSource = childObj?.source;
if (!childSource?.config) { if (!childSource?.config) {
this.node.warn(`registerChild skipped: missing child source/config for id=${childId}`); throw new Error('Missing or invalid child node');
if (typeof done === 'function') done();
return;
} }
// Generate one dashboard for the root source and optionally its registered children.
const dashboards = this.source.generateDashboardsForGraph(childSource, { const dashboards = this.source.generateDashboardsForGraph(childSource, {
includeChildren: Boolean(msg.includeChildren ?? true), includeChildren: Boolean(msg.includeChildren ?? true),
}); });
@@ -72,19 +89,23 @@ class nodeClass {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
if (this.config.grafanaConnector.bearerToken) { if (this.config.grafanaConnector.bearerToken) {
headers.Authorization = `Bearer ${this.config.grafanaConnector.bearerToken}`; headers.Authorization = `Bearer ${this.config.grafanaConnector.bearerToken}`;
} }
for (const dash of dashboards) { 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({ send({
topic: 'grafana.dashboard.upsert', ...msg,
topic: 'create',
url, url,
method: 'POST', method: 'POST',
headers, headers,
payload, payload: this.source.buildUpsertRequest({
dashboard: dash.dashboard,
folderId: 0,
overwrite: true,
}),
meta: { meta: {
nodeId: dash.nodeId, nodeId: dash.nodeId,
softwareType: dash.softwareType, softwareType: dash.softwareType,
@@ -98,7 +119,7 @@ class nodeClass {
} catch (error) { } catch (error) {
this.node.status({ fill: 'red', shape: 'ring', text: 'dashboardapi error' }); this.node.status({ fill: 'red', shape: 'ring', text: 'dashboardapi error' });
this.node.error(error?.message || error, msg); this.node.error(error?.message || error, msg);
if (typeof done === 'function') done(); if (typeof done === 'function') done(error);
} }
}); });
} }

View File

@@ -1,8 +1,7 @@
const test = require('node:test'); describe('dashboardAPI basic structure', () => {
const assert = require('node:assert/strict'); it('module load smoke', () => {
expect(() => {
test('dashboardAPI module load smoke', () => { require('../../dashboardapi.js');
assert.doesNotThrow(() => { }).not.toThrow();
require('../../dashboardapi.js');
}); });
}); });

View File

@@ -1,6 +1,3 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../src/specificClass'); const DashboardApi = require('../src/specificClass');
function makeNodeSource({ id, name, softwareType, positionVsParent, children = [] }) { function makeNodeSource({ id, name, softwareType, positionVsParent, children = [] }) {
@@ -24,59 +21,61 @@ function makeNodeSource({ id, name, softwareType, positionVsParent, children = [
}; };
} }
test('buildDashboard sets id=null, stable uid, title, measurement and bucket vars', () => { describe('DashboardApi specificClass', () => {
const api = new DashboardApi({ it('buildDashboard sets id=null, stable uid, title, measurement and bucket vars', () => {
general: { name: 'dashboardapi-test', logging: { enabled: false, logLevel: 'error' } }, const api = new DashboardApi({
grafanaConnector: { protocol: 'http', host: 'localhost', port: 3000, bearerToken: '' }, general: { name: 'dashboardapi-test', logging: { enabled: false, logLevel: 'error' } },
grafanaConnector: { protocol: 'http', host: 'localhost', port: 3000, bearerToken: '' },
});
const nodeSource = makeNodeSource({
id: 'm-1',
name: 'PT-1',
softwareType: 'measurement',
positionVsParent: 'downstream',
});
const dash = api.buildDashboard({ nodeConfig: nodeSource.config, positionVsParent: 'downstream' });
expect(dash.dashboard.id).toBeNull();
expect(dash.uid).toHaveLength(12);
expect(dash.dashboard.uid).toBe(dash.uid);
expect(dash.dashboard.title).toBe('PT-1');
const templ = dash.dashboard.templating.list;
const measurement = templ.find((v) => v.name === 'measurement');
const bucket = templ.find((v) => v.name === 'bucket');
expect(measurement.current.value).toBe('measurement_m-1');
expect(bucket.current.value).toBe('lvl3');
}); });
const nodeSource = makeNodeSource({ it('generateDashboardsForGraph returns root + direct child dashboards and adds links', () => {
id: 'm-1', const api = new DashboardApi({
name: 'PT-1', general: { name: 'dashboardapi-test', logging: { enabled: false, logLevel: 'error' } },
softwareType: 'measurement', grafanaConnector: { protocol: 'http', host: 'localhost', port: 3000, bearerToken: '' },
positionVsParent: 'downstream', });
const child = makeNodeSource({
id: 'c-1',
name: 'ChildSensor',
softwareType: 'measurement',
positionVsParent: 'upstream',
});
const root = makeNodeSource({
id: 'p-1',
name: 'ParentMachine',
softwareType: 'machine',
positionVsParent: 'atEquipment',
children: [child],
});
const results = api.generateDashboardsForGraph(root, { includeChildren: true });
expect(results).toHaveLength(2);
const rootDash = results[0];
expect(Array.isArray(rootDash.dashboard.links)).toBe(true);
expect(rootDash.dashboard.links.some((l) => l.url && l.url.includes('/d/'))).toBe(true);
}); });
const dash = api.buildDashboard({ nodeConfig: nodeSource.config, positionVsParent: 'downstream' });
assert.equal(dash.dashboard.id, null);
assert.equal(dash.uid.length, 12);
assert.equal(dash.dashboard.uid, dash.uid);
assert.equal(dash.dashboard.title, 'PT-1');
const templ = dash.dashboard.templating.list;
const measurement = templ.find((v) => v.name === 'measurement');
const bucket = templ.find((v) => v.name === 'bucket');
assert.equal(measurement.current.value, 'measurement_m-1');
assert.equal(bucket.current.value, 'lvl3');
});
test('generateDashboardsForGraph returns root + direct child dashboards and adds links', () => {
const api = new DashboardApi({
general: { name: 'dashboardapi-test', logging: { enabled: false, logLevel: 'error' } },
grafanaConnector: { protocol: 'http', host: 'localhost', port: 3000, bearerToken: '' },
});
const child = makeNodeSource({
id: 'c-1',
name: 'ChildSensor',
softwareType: 'measurement',
positionVsParent: 'upstream',
});
const root = makeNodeSource({
id: 'p-1',
name: 'ParentMachine',
softwareType: 'machine',
positionVsParent: 'atEquipment',
children: [child],
});
const results = api.generateDashboardsForGraph(root, { includeChildren: true });
assert.equal(results.length, 2);
const rootDash = results[0];
assert.ok(Array.isArray(rootDash.dashboard.links));
assert.ok(rootDash.dashboard.links.some((l) => l.url && l.url.includes('/d/')));
}); });

View File

@@ -1,11 +1,11 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/basic.flow.json'), 'utf8')); const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/basic.flow.json'), 'utf8'));
test('basic example includes node type dashboardapi', () => { describe('dashboardAPI edge example structure', () => {
it('basic example includes node type dashboardapi', () => {
const count = flow.filter((n) => n && n.type === 'dashboardapi').length; const count = flow.filter((n) => n && n.type === 'dashboardapi').length;
assert.equal(count >= 1, true); expect(count).toBeGreaterThanOrEqual(1);
});
}); });

View File

@@ -1,5 +1,3 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
@@ -9,15 +7,17 @@ function loadJson(file) {
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')); return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
} }
test('examples package exists for dashboardAPI', () => { describe('dashboardAPI integration examples', () => {
for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) { it('examples package exists for dashboardAPI', () => {
assert.equal(fs.existsSync(path.join(dir, file)), true, file + ' missing'); for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
} expect(fs.existsSync(path.join(dir, file))).toBe(true);
}); }
});
test('example flows are parseable arrays for dashboardAPI', () => { it('example flows are parseable arrays for dashboardAPI', () => {
for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) { for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
const parsed = loadJson(file); const parsed = loadJson(file);
assert.equal(Array.isArray(parsed), true); expect(Array.isArray(parsed)).toBe(true);
} }
});
}); });

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

@@ -0,0 +1,179 @@
const NodeClass = require('../src/nodeClass');
jest.mock('../src/specificClass', () => {
return jest.fn().mockImplementation(() => ({
logger: {
warn: jest.fn(),
},
generateDashboardsForGraph: jest.fn(() => [{
dashboard: { title: 'ok' },
nodeId: 'child-node-id',
softwareType: 'measurement',
uid: 'child-uid',
title: 'ok',
}]),
buildUpsertRequest: jest.fn(({ dashboard, folderId, overwrite }) => ({
dashboard,
folderId,
overwrite,
})),
grafanaUpsertUrl: jest.fn(() => 'http://grafana:3000/api/dashboards/db'),
}));
});
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',
url: 'http://grafana:3000/api/dashboards/db',
method: 'POST',
payload: {
dashboard: { title: 'ok' },
folderId: 0,
overwrite: true,
},
}),
);
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' },
folderId: 0,
overwrite: true,
},
}),
);
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' },
folderId: 0,
overwrite: true,
},
}),
);
expect(done).toHaveBeenCalledWith();
});
});