3 Commits

Author SHA1 Message Date
7fdab73ba0 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
2026-05-26 17:53:42 +02:00
znetsixe
dac8576cab style: palette swatch → (domain-hue redesign 2026-05-21)
Sidebar swatch now follows function family rather than S88 level, so the
palette is visually identifiable instead of monochromatically blue. Editor-group
rectangles in flow.json still follow S88 — only the registerType color changed.
Full table + rationale: superproject .claude/rules/node-red-flow-layout.md §10.0
and .claude/refactor/OPEN_QUESTIONS.md (2026-05-21 entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:05:59 +02:00
znetsixe
e04c4a1132 fix: rename dashboardapi.{js,html} → dashboardAPI.{js,html}
Aligns the entry-file naming with the folder-name convention from
.claude/rules/node-architecture.md / superproject CLAUDE.md.

NON-BREAKING: the Node-RED type id stays lowercase
(`RED.nodes.registerType('dashboardapi', ...)`) so every deployed flow
that references this node continues to load. Only the file paths
change. Admin endpoints `/dashboardapi/menu.js` and
`/dashboardapi/configData.js` are unaffected — they follow the type
id, not the filename.

Updated:
- package.json `main` + `node-red.nodes` value
- test/basic/structure-module-load.basic.test.js require path
- CLAUDE.md: legacy-drift warning replaced with a note explaining the
  type-id preservation strategy

Same approach recommended for the remaining two legacy renames (mgc,
vgc); the superproject CLAUDE.md drift table now spells that out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:36:56 +02:00
9 changed files with 87 additions and 19 deletions

View File

@@ -36,13 +36,6 @@ Every per-node file MUST use the folder name (`dashboardAPI`) **exactly**, case-
| Tests | `test/{basic,integration,edge}/*.test.js` |
| Example flows | `examples/*.flow.json` |
> **Legacy naming drift in this repo** — to be renamed when the file is next touched:
>
> | Path | Currently | Should be |
> |---|---|---|
> | Entry file | `dashboardapi.js` | `dashboardAPI.js` |
> | Editor HTML | `dashboardapi.html` | `dashboardAPI.html` |
>
> Renames require updating: the file itself, `package.json#node-red.nodes`, any `require()` / `import` paths, and superproject submodule references in one commit.
> **Note on the Node-RED type id.** The files are now `dashboardAPI.{js,html}` (folder-name convention satisfied 2026-05-19), but the registered type id stays lowercase: `RED.nodes.registerType('dashboardapi', …)`. Every deployed flow references the type id, not the file name, so this preserves backward compatibility. Admin endpoints (`/dashboardapi/menu.js`, `/dashboardapi/configData.js`) follow the type id and are also unchanged.
When adding new files, read the rule above first to avoid drift.

View File

@@ -4,7 +4,7 @@
<script>
RED.nodes.registerType('dashboardapi', {
category: 'EVOLV',
color: '#4f8582',
color: '#7A8BA3',
defaults: {
name: { value: '' },
enableLog: { value: true },
@@ -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).
},
});
</script>
@@ -80,7 +84,12 @@
<div class="form-row">
<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 class="form-row">

View File

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

View File

@@ -2,7 +2,7 @@
"name": "dashboardAPI",
"version": "1.0.0",
"description": "EVOLV Grafana dashboard generator (Node-RED node).",
"main": "dashboardapi.js",
"main": "dashboardAPI.js",
"scripts": {
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js",
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
@@ -22,7 +22,7 @@
},
"node-red": {
"nodes": {
"dashboardapi": "dashboardapi.js"
"dashboardapi": "dashboardAPI.js"
}
}
}

View File

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

View File

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

View File

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

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

View File

@@ -3,6 +3,6 @@ const assert = require('node:assert/strict');
test('dashboardAPI module load smoke', () => {
assert.doesNotThrow(() => {
require('../../dashboardapi.js');
require('../../dashboardAPI.js');
});
});