Fix dashboardapi adapter and Jest coverage

This commit is contained in:
Rene De Ren
2026-03-12 16:46:50 +01:00
parent c5272fcc24
commit 66b91883ac
6 changed files with 163 additions and 118 deletions

View File

@@ -6,6 +6,8 @@ class nodeClass {
this.node = nodeInstance;
this.RED = RED;
this.name = nameOfNode;
this.source = null;
this.config = null;
this._loadConfig(uiConfig);
this._setupSpecificClass();
@@ -21,11 +23,12 @@ class nodeClass {
role: 'auto ui generator',
},
grafanaConnector: {
host: uiConfig.host || 'localhost',
port: Number(uiConfig.port) || 3000,
protocol: uiConfig.protocol || 'http',
bearerToken: uiConfig.bearerToken || null,
host: uiConfig.host || 'localhost',
port: Number(uiConfig.port || 3000),
bearerToken: uiConfig.bearerToken || '',
},
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',
});
}
@@ -48,17 +51,17 @@ class nodeClass {
return runtimeNode || flowNode || null;
}
_resolveChildConfig(payload) {
_resolveChildSource(payload) {
if (payload?.source?.config) {
return payload.source.config;
return payload.source;
}
if (payload?.config) {
return payload.config;
return { config: payload.config };
}
if (typeof payload === 'string') {
return this._resolveChildNode(payload)?.source?.config || null;
return this._resolveChildNode(payload)?.source || null;
}
return null;
@@ -67,45 +70,63 @@ class nodeClass {
_attachInputHandler() {
this.node.on('input', async (msg, send, done) => {
try {
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;
if (msg.topic !== 'registerChild') {
if (typeof done === 'function') done();
return;
}
done();
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),
});
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: 'Bad request data' });
this.node.error(`Bad request data: ${error.message}`, msg);
done(error);
this.node.status({ fill: 'red', shape: 'ring', text: 'dashboardapi error' });
this.node.error(error?.message || error, msg);
if (typeof done === 'function') done(error);
}
});
}
_attachCloseHandler() {
this.node.on('close', (done) => {
done();
if (typeof done === 'function') done();
});
}
}

View File

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

View File

@@ -1,6 +1,3 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../src/specificClass');
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', () => {
const api = new DashboardApi({
general: { name: 'dashboardapi-test', logging: { enabled: false, logLevel: 'error' } },
grafanaConnector: { protocol: 'http', host: 'localhost', port: 3000, bearerToken: '' },
describe('DashboardApi specificClass', () => {
it('buildDashboard sets id=null, stable uid, title, measurement and bucket vars', () => {
const api = new DashboardApi({
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({
id: 'm-1',
name: 'PT-1',
softwareType: 'measurement',
positionVsParent: 'downstream',
it('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 });
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 path = require('node:path');
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;
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 path = require('node:path');
@@ -9,15 +7,17 @@ function loadJson(file) {
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
}
test('examples package exists for dashboardAPI', () => {
for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
assert.equal(fs.existsSync(path.join(dir, file)), true, file + ' missing');
}
});
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);
}
});
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.equal(Array.isArray(parsed), 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);
}
});
});

View File

@@ -5,7 +5,19 @@ jest.mock('../src/specificClass', () => {
logger: {
warn: jest.fn(),
},
generateDashB: jest.fn().mockResolvedValue({ dashboard: { title: 'ok' } }),
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'),
}));
});
@@ -77,7 +89,13 @@ describe('dashboardAPI nodeClass', () => {
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
topic: 'create',
payload: { dashboard: { title: 'ok' } },
url: 'http://grafana:3000/api/dashboards/db',
method: 'POST',
payload: {
dashboard: { title: 'ok' },
folderId: 0,
overwrite: true,
},
}),
);
expect(done).toHaveBeenCalledWith();
@@ -109,7 +127,11 @@ describe('dashboardAPI nodeClass', () => {
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
topic: 'create',
payload: { dashboard: { title: 'ok' } },
payload: {
dashboard: { title: 'ok' },
folderId: 0,
overwrite: true,
},
}),
);
expect(done).toHaveBeenCalledWith();
@@ -145,7 +167,11 @@ describe('dashboardAPI nodeClass', () => {
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
topic: 'create',
payload: { dashboard: { title: 'ok' } },
payload: {
dashboard: { title: 'ok' },
folderId: 0,
overwrite: true,
},
}),
);
expect(done).toHaveBeenCalledWith();