feat(dashboardapi): walking skeleton for graph-aware Grafana generator (#34)

Encrypts the Grafana bearer token via Node-RED credentials block instead of
plain config (F-11). Adds folderUid config field threaded through to the
buildUpsertRequest payload (F-8, resolves PRD O-5). Migration path: legacy
plain bearerToken still loads, with one-time warn() prompting user to re-save.

Composition + URL + headers + per-instance UID were already in place; only
the credentials + folderUid + tests are new.

- dashboardAPI.html: bearerToken moved to credentials block; folderUid added.
- dashboardAPI.js: registerType options pass credentials descriptor.
- src/nodeClass.js: read token from node.credentials; legacy fallback warns.
- src/specificClass.js: buildUpsertRequest emits folderUid when set.
- src/commands/handlers.js: pass folderUid from config to buildUpsertRequest.
- test/basic/slice34-credentials-and-folder.basic.test.js: 5 new tests.

Diff-based regeneration (F-1) and the explicit flows:started lifecycle hook
land in #36 once the S1 spike predicate is wired. Until then, the existing
child.register message trigger continues to drive composition on every
startup-time child registration.

Closes #34
This commit is contained in:
2026-05-26 17:53:42 +02:00
parent dac8576cab
commit 7fdab73ba0
6 changed files with 82 additions and 7 deletions

View File

@@ -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">

View File

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

View File

@@ -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: {

View File

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

View File

@@ -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) {

View 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, '');
});