From 7fdab73ba0bb81d9ad23c355ede9bd4bff62bc70 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Tue, 26 May 2026 17:53:42 +0200 Subject: [PATCH] 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 --- dashboardAPI.html | 15 +++++-- dashboardAPI.js | 4 ++ src/commands/handlers.js | 2 +- src/nodeClass.js | 15 ++++++- src/specificClass.js | 10 ++++- ...ice34-credentials-and-folder.basic.test.js | 43 +++++++++++++++++++ 6 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 test/basic/slice34-credentials-and-folder.basic.test.js diff --git a/dashboardAPI.html b/dashboardAPI.html index 9cc5b4c..9c6a866 100644 --- a/dashboardAPI.html +++ b/dashboardAPI.html @@ -13,9 +13,12 @@ protocol: { value: 'http' }, host: { value: 'localhost' }, port: { value: 3000 }, - bearerToken: { value: '' }, + folderUid: { value: '' }, defaultBucket: { value: '' }, }, + credentials: { + bearerToken: { type: 'password' }, + }, inputs: 1, outputs: 1, inputLabels: ['Input'], @@ -44,11 +47,12 @@ 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}`); if (!element) return; 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). }, }); @@ -80,7 +84,12 @@
- + +
+ +
+ +
diff --git a/dashboardAPI.js b/dashboardAPI.js index 06d71cd..45a84d1 100644 --- a/dashboardAPI.js +++ b/dashboardAPI.js @@ -9,6 +9,10 @@ module.exports = function (RED) { RED.nodes.registerType(nameOfNode, function (config) { RED.nodes.createNode(this, config); this.nodeClass = new nodeClass(config, RED, this, nameOfNode); + }, { + credentials: { + bearerToken: { type: 'password' }, + }, }); const menuMgr = new MenuManager(); diff --git a/src/commands/handlers.js b/src/commands/handlers.js index e735997..4916ed6 100644 --- a/src/commands/handlers.js +++ b/src/commands/handlers.js @@ -48,7 +48,7 @@ function registerChild(source, msg, ctx) { headers, payload: source.buildUpsertRequest({ dashboard: dash.dashboard, - folderId: 0, + folderUid: source.config?.grafanaConnector?.folderUid || undefined, overwrite: true, }), meta: { diff --git a/src/nodeClass.js b/src/nodeClass.js index 85a3861..3c7f7d7 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -30,6 +30,18 @@ class nodeClass { _buildConfig(uiConfig) { 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, { functionality: { softwareType: this.name.toLowerCase(), @@ -39,7 +51,8 @@ class nodeClass { protocol: uiConfig.protocol || 'http', host: uiConfig.host || 'localhost', port: Number(uiConfig.port || 3000), - bearerToken: uiConfig.bearerToken || '', + bearerToken, + folderUid: uiConfig.folderUid || '', }, defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '', }); diff --git a/src/specificClass.js b/src/specificClass.js index 7fdebfd..911a316 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -64,6 +64,7 @@ class DashboardApi { host: config?.grafanaConnector?.host || 'localhost', port: Number(config?.grafanaConnector?.port || 3000), bearerToken: config?.grafanaConnector?.bearerToken || '', + folderUid: config?.grafanaConnector?.folderUid || '', }, defaultBucket: config?.defaultBucket || '', bucketMap: config?.bucketMap || {}, @@ -144,8 +145,13 @@ class DashboardApi { return { dashboard, uid, title, softwareType, nodeId, measurementName }; } - buildUpsertRequest({ dashboard, folderId = 0, overwrite = true }) { - return { dashboard, folderId, overwrite }; + buildUpsertRequest({ dashboard, folderId, folderUid, overwrite = true }) { + 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) { diff --git a/test/basic/slice34-credentials-and-folder.basic.test.js b/test/basic/slice34-credentials-and-folder.basic.test.js new file mode 100644 index 0000000..df7771d --- /dev/null +++ b/test/basic/slice34-credentials-and-folder.basic.test.js @@ -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, ''); +});