Compare commits
3 Commits
e04c4a1132
...
slice/35-m
| Author | SHA1 | Date | |
|---|---|---|---|
| bdf87ffd67 | |||
| 7fdab73ba0 | |||
|
|
dac8576cab |
@@ -4,7 +4,7 @@
|
|||||||
<script>
|
<script>
|
||||||
RED.nodes.registerType('dashboardapi', {
|
RED.nodes.registerType('dashboardapi', {
|
||||||
category: 'EVOLV',
|
category: 'EVOLV',
|
||||||
color: '#4f8582',
|
color: '#7A8BA3',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: { value: '' },
|
name: { value: '' },
|
||||||
enableLog: { value: true },
|
enableLog: { value: true },
|
||||||
@@ -13,9 +13,12 @@
|
|||||||
protocol: { value: 'http' },
|
protocol: { value: 'http' },
|
||||||
host: { value: 'localhost' },
|
host: { value: 'localhost' },
|
||||||
port: { value: 3000 },
|
port: { value: 3000 },
|
||||||
bearerToken: { value: '' },
|
folderUid: { value: '' },
|
||||||
defaultBucket: { value: '' },
|
defaultBucket: { value: '' },
|
||||||
},
|
},
|
||||||
|
credentials: {
|
||||||
|
bearerToken: { type: 'password' },
|
||||||
|
},
|
||||||
inputs: 1,
|
inputs: 1,
|
||||||
outputs: 1,
|
outputs: 1,
|
||||||
inputLabels: ['Input'],
|
inputLabels: ['Input'],
|
||||||
@@ -44,11 +47,12 @@
|
|||||||
window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node);
|
window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
['name', 'protocol', 'host', 'port', 'bearerToken', 'defaultBucket'].forEach((field) => {
|
['name', 'protocol', 'host', 'port', 'folderUid', 'defaultBucket'].forEach((field) => {
|
||||||
const element = document.getElementById(`node-input-${field}`);
|
const element = document.getElementById(`node-input-${field}`);
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
node[field] = field === 'port' ? parseInt(element.value, 10) || 3000 : element.value || '';
|
node[field] = field === 'port' ? parseInt(element.value, 10) || 3000 : element.value || '';
|
||||||
});
|
});
|
||||||
|
// bearerToken is handled by Node-RED's credentials system (encrypted at rest in flow_cred.json).
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -80,7 +84,12 @@
|
|||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-bearerToken"><i class="fa fa-key"></i> Bearer Token</label>
|
<label for="node-input-bearerToken"><i class="fa fa-key"></i> Bearer Token</label>
|
||||||
<input type="password" id="node-input-bearerToken" placeholder="optional" style="width:70%;" />
|
<input type="password" id="node-input-bearerToken" placeholder="encrypted at rest" style="width:70%;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-folderUid"><i class="fa fa-folder-open"></i> Grafana Folder UID</label>
|
||||||
|
<input type="text" id="node-input-folderUid" placeholder="optional — empty = General folder" style="width:70%;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ module.exports = function (RED) {
|
|||||||
RED.nodes.registerType(nameOfNode, function (config) {
|
RED.nodes.registerType(nameOfNode, function (config) {
|
||||||
RED.nodes.createNode(this, config);
|
RED.nodes.createNode(this, config);
|
||||||
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
|
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
|
||||||
|
}, {
|
||||||
|
credentials: {
|
||||||
|
bearerToken: { type: 'password' },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const menuMgr = new MenuManager();
|
const menuMgr = new MenuManager();
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function registerChild(source, msg, ctx) {
|
|||||||
headers,
|
headers,
|
||||||
payload: source.buildUpsertRequest({
|
payload: source.buildUpsertRequest({
|
||||||
dashboard: dash.dashboard,
|
dashboard: dash.dashboard,
|
||||||
folderId: 0,
|
folderUid: source.config?.grafanaConnector?.folderUid || undefined,
|
||||||
overwrite: true,
|
overwrite: true,
|
||||||
}),
|
}),
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ class nodeClass {
|
|||||||
|
|
||||||
_buildConfig(uiConfig) {
|
_buildConfig(uiConfig) {
|
||||||
const cfgMgr = new configManager();
|
const cfgMgr = new configManager();
|
||||||
|
// Credentials block (Node-RED encrypts at rest in flow_cred.json). Legacy
|
||||||
|
// installs may still carry bearerToken on uiConfig — fall back with a
|
||||||
|
// one-time deprecation warning so the user knows to re-save.
|
||||||
|
const credentialToken = this.node?.credentials?.bearerToken || '';
|
||||||
|
const legacyToken = uiConfig.bearerToken || '';
|
||||||
|
if (!credentialToken && legacyToken) {
|
||||||
|
this.RED?.log?.warn?.(
|
||||||
|
`[${this.name}] bearer token loaded from legacy plain config field. ` +
|
||||||
|
`Re-open this node in the editor and click Done to migrate to encrypted credentials.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const bearerToken = credentialToken || legacyToken;
|
||||||
return cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
|
return cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
|
||||||
functionality: {
|
functionality: {
|
||||||
softwareType: this.name.toLowerCase(),
|
softwareType: this.name.toLowerCase(),
|
||||||
@@ -39,7 +51,8 @@ class nodeClass {
|
|||||||
protocol: uiConfig.protocol || 'http',
|
protocol: uiConfig.protocol || 'http',
|
||||||
host: uiConfig.host || 'localhost',
|
host: uiConfig.host || 'localhost',
|
||||||
port: Number(uiConfig.port || 3000),
|
port: Number(uiConfig.port || 3000),
|
||||||
bearerToken: uiConfig.bearerToken || '',
|
bearerToken,
|
||||||
|
folderUid: uiConfig.folderUid || '',
|
||||||
},
|
},
|
||||||
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',
|
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class DashboardApi {
|
|||||||
host: config?.grafanaConnector?.host || 'localhost',
|
host: config?.grafanaConnector?.host || 'localhost',
|
||||||
port: Number(config?.grafanaConnector?.port || 3000),
|
port: Number(config?.grafanaConnector?.port || 3000),
|
||||||
bearerToken: config?.grafanaConnector?.bearerToken || '',
|
bearerToken: config?.grafanaConnector?.bearerToken || '',
|
||||||
|
folderUid: config?.grafanaConnector?.folderUid || '',
|
||||||
},
|
},
|
||||||
defaultBucket: config?.defaultBucket || '',
|
defaultBucket: config?.defaultBucket || '',
|
||||||
bucketMap: config?.bucketMap || {},
|
bucketMap: config?.bucketMap || {},
|
||||||
@@ -144,8 +145,13 @@ class DashboardApi {
|
|||||||
return { dashboard, uid, title, softwareType, nodeId, measurementName };
|
return { dashboard, uid, title, softwareType, nodeId, measurementName };
|
||||||
}
|
}
|
||||||
|
|
||||||
buildUpsertRequest({ dashboard, folderId = 0, overwrite = true }) {
|
buildUpsertRequest({ dashboard, folderId, folderUid, overwrite = true }) {
|
||||||
return { dashboard, folderId, overwrite };
|
const out = { dashboard, overwrite };
|
||||||
|
// Prefer folderUid (modern Grafana API). Fall back to folderId for older callers.
|
||||||
|
const uid = folderUid ?? this.config?.grafanaConnector?.folderUid ?? '';
|
||||||
|
if (uid) out.folderUid = uid;
|
||||||
|
else if (typeof folderId === 'number') out.folderId = folderId;
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
extractChildren(nodeSource) {
|
extractChildren(nodeSource) {
|
||||||
|
|||||||
43
test/basic/slice34-credentials-and-folder.basic.test.js
Normal file
43
test/basic/slice34-credentials-and-folder.basic.test.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
|
||||||
|
test('buildUpsertRequest emits folderUid when configured', () => {
|
||||||
|
const api = new DashboardApi({
|
||||||
|
grafanaConnector: { folderUid: 'rnd-folder' },
|
||||||
|
});
|
||||||
|
const req = api.buildUpsertRequest({ dashboard: { uid: 'x', title: 'X' } });
|
||||||
|
assert.equal(req.folderUid, 'rnd-folder');
|
||||||
|
assert.equal(req.overwrite, true);
|
||||||
|
assert.ok(!('folderId' in req), 'should not emit folderId when folderUid is set');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildUpsertRequest omits folderUid when empty (Grafana defaults to General)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const req = api.buildUpsertRequest({ dashboard: { uid: 'x' } });
|
||||||
|
assert.equal(req.folderUid, undefined);
|
||||||
|
// folderId fallback only when explicitly passed
|
||||||
|
assert.equal(req.folderId, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildUpsertRequest folderUid override at call-site wins over config', () => {
|
||||||
|
const api = new DashboardApi({ grafanaConnector: { folderUid: 'rnd-folder' } });
|
||||||
|
const req = api.buildUpsertRequest({ dashboard: { uid: 'x' }, folderUid: 'override-folder' });
|
||||||
|
assert.equal(req.folderUid, 'override-folder');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bearerToken from config flows into specificClass config', () => {
|
||||||
|
const api = new DashboardApi({
|
||||||
|
grafanaConnector: { bearerToken: 'tok-xyz', folderUid: '' },
|
||||||
|
});
|
||||||
|
assert.equal(api.config.grafanaConnector.bearerToken, 'tok-xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default config has empty bearerToken and folderUid', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
assert.equal(api.config.grafanaConnector.bearerToken, '');
|
||||||
|
assert.equal(api.config.grafanaConnector.folderUid, '');
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
|
||||||
|
function makeChild(i, softwareType = 'measurement', positionVsParent = 'downstream') {
|
||||||
|
return {
|
||||||
|
child: {
|
||||||
|
config: {
|
||||||
|
general: { id: `child-${i}`, name: `Child ${i}` },
|
||||||
|
functionality: { softwareType, positionVsParent },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
softwareType,
|
||||||
|
position: positionVsParent,
|
||||||
|
registeredAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRoot(children) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const c of children) map.set(c.child.config.general.id, c);
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
general: { id: 'root-1', name: 'Root' },
|
||||||
|
functionality: { softwareType: 'dashboardapi', positionVsParent: 'atequipment' },
|
||||||
|
},
|
||||||
|
childRegistrationUtils: { registeredChildren: map },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('generateDashboardsForGraph composes 50 children in <500ms', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const children = Array.from({ length: 50 }, (_, i) => makeChild(i));
|
||||||
|
const root = makeRoot(children);
|
||||||
|
|
||||||
|
const t0 = process.hrtime.bigint();
|
||||||
|
const dashboards = api.generateDashboardsForGraph(root, { includeChildren: true });
|
||||||
|
const t1 = process.hrtime.bigint();
|
||||||
|
|
||||||
|
const durationMs = Number(t1 - t0) / 1e6;
|
||||||
|
assert.ok(durationMs < 500, `composition took ${durationMs.toFixed(1)}ms, expected <500ms`);
|
||||||
|
assert.ok(dashboards.length >= 1, 'should produce at least the root dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uids are unique across all generated dashboards (no collision risk)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const children = Array.from({ length: 30 }, (_, i) => makeChild(i, 'measurement'));
|
||||||
|
const root = makeRoot(children);
|
||||||
|
const dashboards = api.generateDashboardsForGraph(root);
|
||||||
|
const uids = dashboards.map((d) => d.uid);
|
||||||
|
const unique = new Set(uids);
|
||||||
|
assert.equal(unique.size, uids.length, `expected ${uids.length} unique uids, got ${unique.size}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byte-identical composition under repeat (idempotency)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const children = Array.from({ length: 5 }, (_, i) => makeChild(i));
|
||||||
|
const root = makeRoot(children);
|
||||||
|
const first = JSON.stringify(api.generateDashboardsForGraph(root).map((d) => d.dashboard));
|
||||||
|
const second = JSON.stringify(api.generateDashboardsForGraph(root).map((d) => d.dashboard));
|
||||||
|
assert.equal(first, second, 'two consecutive compositions should produce byte-identical JSON');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('root dashboard links to every child dashboard', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const children = Array.from({ length: 4 }, (_, i) => makeChild(i));
|
||||||
|
const root = makeRoot(children);
|
||||||
|
const dashboards = api.generateDashboardsForGraph(root);
|
||||||
|
const rootDash = dashboards[0].dashboard;
|
||||||
|
assert.ok(Array.isArray(rootDash.links), 'root dashboard should have links array');
|
||||||
|
assert.equal(rootDash.links.length, 4, 'one link per registered child');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user