Compare commits
2 Commits
1ea4788848
...
547333be7d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
547333be7d | ||
|
|
b285d8e83a |
8
examples/README.md
Normal file
8
examples/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# dashboardAPI Example Flows
|
||||||
|
|
||||||
|
Import-ready Node-RED examples for dashboardAPI.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- basic.flow.json
|
||||||
|
- integration.flow.json
|
||||||
|
- edge.flow.json
|
||||||
6
examples/basic.flow.json
Normal file
6
examples/basic.flow.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{"id":"dashboardAPI_basic_tab","type":"tab","label":"dashboardAPI basic","disabled":false,"info":"dashboardAPI basic example"},
|
||||||
|
{"id":"dashboardAPI_basic_node","type":"dashboardapi","z":"dashboardAPI_basic_tab","name":"dashboardAPI basic","x":420,"y":180,"wires":[["dashboardAPI_basic_dbg"]]},
|
||||||
|
{"id":"dashboardAPI_basic_inj","type":"inject","z":"dashboardAPI_basic_tab","name":"basic trigger","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"ping","payload":"1","payloadType":"str","x":160,"y":180,"wires":[["dashboardAPI_basic_node"]]},
|
||||||
|
{"id":"dashboardAPI_basic_dbg","type":"debug","z":"dashboardAPI_basic_tab","name":"dashboardAPI basic debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
|
||||||
|
]
|
||||||
6
examples/edge.flow.json
Normal file
6
examples/edge.flow.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{"id":"dashboardAPI_edge_tab","type":"tab","label":"dashboardAPI edge","disabled":false,"info":"dashboardAPI edge example"},
|
||||||
|
{"id":"dashboardAPI_edge_node","type":"dashboardapi","z":"dashboardAPI_edge_tab","name":"dashboardAPI edge","x":420,"y":180,"wires":[["dashboardAPI_edge_dbg"]]},
|
||||||
|
{"id":"dashboardAPI_edge_inj","type":"inject","z":"dashboardAPI_edge_tab","name":"unknown topic","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"doesNotExist","payload":"x","payloadType":"str","x":170,"y":180,"wires":[["dashboardAPI_edge_node"]]},
|
||||||
|
{"id":"dashboardAPI_edge_dbg","type":"debug","z":"dashboardAPI_edge_tab","name":"dashboardAPI edge debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
|
||||||
|
]
|
||||||
6
examples/integration.flow.json
Normal file
6
examples/integration.flow.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{"id":"dashboardAPI_int_tab","type":"tab","label":"dashboardAPI integration","disabled":false,"info":"dashboardAPI integration example"},
|
||||||
|
{"id":"dashboardAPI_int_node","type":"dashboardapi","z":"dashboardAPI_int_tab","name":"dashboardAPI integration","x":420,"y":180,"wires":[["dashboardAPI_int_dbg"]]},
|
||||||
|
{"id":"dashboardAPI_int_inj","type":"inject","z":"dashboardAPI_int_tab","name":"registerChild","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"registerChild","payload":"example-child-id","payloadType":"str","x":170,"y":180,"wires":[["dashboardAPI_int_node"]]},
|
||||||
|
{"id":"dashboardAPI_int_dbg","type":"debug","z":"dashboardAPI_int_tab","name":"dashboardAPI integration debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":680,"y":180,"wires":[]}
|
||||||
|
]
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "EVOLV Grafana dashboard generator (Node-RED node).",
|
"description": "EVOLV Grafana dashboard generator (Node-RED node).",
|
||||||
"main": "dashboardapi.js",
|
"main": "dashboardapi.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test test/*.test.js"
|
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"dashboard",
|
"dashboard",
|
||||||
@@ -23,4 +23,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
const { outputUtils } = require('generalFunctions');
|
const { outputUtils } = require('generalFunctions');
|
||||||
const Specific = require('./specificClass');
|
const Specific = 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;
|
||||||
@@ -44,7 +48,7 @@ class nodeClass {
|
|||||||
this.node.on('input', async (msg, send, done) => {
|
this.node.on('input', async (msg, send, done) => {
|
||||||
try {
|
try {
|
||||||
if (msg.topic !== 'registerChild') {
|
if (msg.topic !== 'registerChild') {
|
||||||
done();
|
if (typeof done === 'function') done();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +56,12 @@ class nodeClass {
|
|||||||
const childObj = this.RED.nodes.getNode(childId);
|
const childObj = this.RED.nodes.getNode(childId);
|
||||||
const childSource = childObj?.source;
|
const childSource = childObj?.source;
|
||||||
if (!childSource?.config) {
|
if (!childSource?.config) {
|
||||||
throw new Error(`Missing child source/config for id=${childId}`);
|
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, {
|
const dashboards = this.source.generateDashboardsForGraph(childSource, {
|
||||||
includeChildren: Boolean(msg.includeChildren ?? true),
|
includeChildren: Boolean(msg.includeChildren ?? true),
|
||||||
});
|
});
|
||||||
@@ -69,6 +76,7 @@ class nodeClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 });
|
const payload = this.source.buildUpsertRequest({ dashboard: dash.dashboard, folderId: 0, overwrite: true });
|
||||||
send({
|
send({
|
||||||
topic: 'grafana.dashboard.upsert',
|
topic: 'grafana.dashboard.upsert',
|
||||||
@@ -85,19 +93,20 @@ class nodeClass {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
done();
|
if (typeof done === 'function') done();
|
||||||
} 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);
|
||||||
done(error);
|
if (typeof done === 'function') done();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_attachCloseHandler() {
|
_attachCloseHandler() {
|
||||||
this.node.on('close', (done) => done());
|
this.node.on('close', (done) => {
|
||||||
|
if (typeof done === 'function') done();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = nodeClass;
|
module.exports = nodeClass;
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ function updateTemplatingVar(dashboard, varName, value) {
|
|||||||
variable.query = value;
|
variable.query = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard domain service.
|
||||||
|
* Builds Grafana dashboard payloads from EVOLV node config and child topology.
|
||||||
|
*/
|
||||||
class DashboardApi {
|
class DashboardApi {
|
||||||
constructor(config = {}) {
|
constructor(config = {}) {
|
||||||
this.config = {
|
this.config = {
|
||||||
@@ -88,11 +92,13 @@ class DashboardApi {
|
|||||||
if (fs.existsSync(fullPath)) return fullPath;
|
if (fs.existsSync(fullPath)) return fullPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`No dashboard template found for softwareType=${st}`);
|
this.logger.warn(`No dashboard template found for softwareType=${st}`);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadTemplate(softwareType) {
|
loadTemplate(softwareType) {
|
||||||
const templatePath = this._templateFileForSoftwareType(softwareType);
|
const templatePath = this._templateFileForSoftwareType(softwareType);
|
||||||
|
if (!templatePath) return null;
|
||||||
const raw = fs.readFileSync(templatePath, 'utf8');
|
const raw = fs.readFileSync(templatePath, 'utf8');
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
}
|
}
|
||||||
@@ -111,7 +117,12 @@ class DashboardApi {
|
|||||||
const measurementName = `${softwareType}_${nodeId}`;
|
const measurementName = `${softwareType}_${nodeId}`;
|
||||||
const title = nodeConfig?.general?.name || String(nodeId);
|
const title = nodeConfig?.general?.name || String(nodeId);
|
||||||
|
|
||||||
|
// Missing templates are treated as non-fatal: we skip only that dashboard.
|
||||||
const dashboard = this.loadTemplate(softwareType);
|
const dashboard = this.loadTemplate(softwareType);
|
||||||
|
if (!dashboard) {
|
||||||
|
this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const uid = stableUid(`${softwareType}:${nodeId}`);
|
const uid = stableUid(`${softwareType}:${nodeId}`);
|
||||||
|
|
||||||
dashboard.id = null;
|
dashboard.id = null;
|
||||||
@@ -150,11 +161,13 @@ class DashboardApi {
|
|||||||
|
|
||||||
generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) {
|
generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) {
|
||||||
if (!rootSource?.config) {
|
if (!rootSource?.config) {
|
||||||
throw new Error('generateDashboardsForGraph requires a node source with `.config`');
|
this.logger.warn('generateDashboardsForGraph skipped: root source missing config');
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootPosition = rootSource?.positionVsParent || rootSource?.config?.functionality?.positionVsParent;
|
const rootPosition = rootSource?.positionVsParent || rootSource?.config?.functionality?.positionVsParent;
|
||||||
const rootDash = this.buildDashboard({ nodeConfig: rootSource.config, positionVsParent: rootPosition });
|
const rootDash = this.buildDashboard({ nodeConfig: rootSource.config, positionVsParent: rootPosition });
|
||||||
|
if (!rootDash) return [];
|
||||||
|
|
||||||
const results = [rootDash];
|
const results = [rootDash];
|
||||||
|
|
||||||
@@ -163,7 +176,7 @@ class DashboardApi {
|
|||||||
const children = this.extractChildren(rootSource);
|
const children = this.extractChildren(rootSource);
|
||||||
for (const { childSource, positionVsParent } of children) {
|
for (const { childSource, positionVsParent } of children) {
|
||||||
const childDash = this.buildDashboard({ nodeConfig: childSource.config, positionVsParent });
|
const childDash = this.buildDashboard({ nodeConfig: childSource.config, positionVsParent });
|
||||||
results.push(childDash);
|
if (childDash) results.push(childDash);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add links from the root dashboard to children dashboards (when possible)
|
// Add links from the root dashboard to children dashboards (when possible)
|
||||||
|
|||||||
12
test/README.md
Normal file
12
test/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# dashboardAPI Test Suite Layout
|
||||||
|
|
||||||
|
Required EVOLV layout:
|
||||||
|
- basic/
|
||||||
|
- integration/
|
||||||
|
- edge/
|
||||||
|
- helpers/
|
||||||
|
|
||||||
|
Baseline structure tests:
|
||||||
|
- basic/structure-module-load.basic.test.js
|
||||||
|
- integration/structure-examples.integration.test.js
|
||||||
|
- edge/structure-examples-node-type.edge.test.js
|
||||||
0
test/basic/.gitkeep
Normal file
0
test/basic/.gitkeep
Normal file
8
test/basic/structure-module-load.basic.test.js
Normal file
8
test/basic/structure-module-load.basic.test.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
test('dashboardAPI module load smoke', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
require('../../dashboardapi.js');
|
||||||
|
});
|
||||||
|
});
|
||||||
0
test/edge/.gitkeep
Normal file
0
test/edge/.gitkeep
Normal file
11
test/edge/structure-examples-node-type.edge.test.js
Normal file
11
test/edge/structure-examples-node-type.edge.test.js
Normal file
@@ -0,0 +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', () => {
|
||||||
|
const count = flow.filter((n) => n && n.type === 'dashboardapi').length;
|
||||||
|
assert.equal(count >= 1, true);
|
||||||
|
});
|
||||||
0
test/helpers/.gitkeep
Normal file
0
test/helpers/.gitkeep
Normal file
0
test/integration/.gitkeep
Normal file
0
test/integration/.gitkeep
Normal file
23
test/integration/structure-examples.integration.test.js
Normal file
23
test/integration/structure-examples.integration.test.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const dir = path.resolve(__dirname, '../../examples');
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user