diff --git a/dashboardAPI.html b/dashboardAPI.html
index 9c6a866..c11541f 100644
--- a/dashboardAPI.html
+++ b/dashboardAPI.html
@@ -13,6 +13,7 @@
protocol: { value: 'http' },
host: { value: 'localhost' },
port: { value: 3000 },
+ folderTitle: { value: '' },
folderUid: { value: '' },
defaultBucket: { value: '' },
},
@@ -47,7 +48,7 @@
window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node);
}
- ['name', 'protocol', 'host', 'port', 'folderUid', 'defaultBucket'].forEach((field) => {
+ ['name', 'protocol', 'host', 'port', 'folderTitle', '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 || '';
@@ -87,9 +88,14 @@
+
+
+
+
+
-
+
diff --git a/src/commands/handlers.js b/src/commands/handlers.js
index 9d44f4f..29f57a8 100644
--- a/src/commands/handlers.js
+++ b/src/commands/handlers.js
@@ -24,7 +24,7 @@ function resolveChildNode(childId, ctx) {
// Shared emit path used by both child.register (auto, deploy-driven) and
// regenerate-dashboard (manual). `trigger` distinguishes the two for logs.
-function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
+async function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
const dashboards = source.generateDashboardsForGraph(childSource, {
includeChildren: Boolean(msg.includeChildren ?? true),
});
@@ -34,6 +34,13 @@ function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
const token = source.config?.grafanaConnector?.bearerToken;
if (token) headers.Authorization = `Bearer ${token}`;
+ // Resolve the folder by name (creating it if missing) so a rebuilt Grafana's
+ // fresh folder uid never strands the upserts on a stale pinned uid. Falls
+ // back to the configured folderUid on any failure.
+ const folderUid = typeof source.resolveFolderUid === 'function'
+ ? await source.resolveFolderUid()
+ : (source.config?.grafanaConnector?.folderUid || undefined);
+
for (const dash of dashboards) {
ctx.send({
...msg,
@@ -43,7 +50,7 @@ function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
headers,
payload: source.buildUpsertRequest({
dashboard: dash.dashboard,
- folderUid: source.config?.grafanaConnector?.folderUid || undefined,
+ folderUid: folderUid || undefined,
overwrite: true,
}),
meta: {
@@ -74,7 +81,7 @@ function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
// payload's `diff` indicates that NEITHER the dashboardAPI itself NOR this
// child NOR its grandchildren changed, skip composition and log no-diff. The
// first call after startup (no cached diff yet) regenerates unconditionally.
-function registerChild(source, msg, ctx) {
+async function registerChild(source, msg, ctx) {
const childSource = resolveChildSource(msg.payload, ctx);
if (!childSource?.config) {
throw new Error('Missing or invalid child node');
@@ -99,13 +106,13 @@ function registerChild(source, msg, ctx) {
return;
}
- emitDashboardsFor(source, childSource, ctx, msg, 'child.register');
+ await emitDashboardsFor(source, childSource, ctx, msg, 'child.register');
}
// On regenerate-dashboard: re-emit dashboards for every cached child source,
// bypassing the diff predicate. Useful as an operator escape hatch when
// auto-regen missed an edge case or when the operator just wants to refresh.
-function regenerateDashboard(source, msg, ctx) {
+async function regenerateDashboard(source, msg, ctx) {
const cached = source.cachedChildSources?.() || [];
if (source.logger?.info) {
source.logger.info({
@@ -116,7 +123,7 @@ function regenerateDashboard(source, msg, ctx) {
});
}
for (const childSource of cached) {
- emitDashboardsFor(source, childSource, ctx, msg, 'manual');
+ await emitDashboardsFor(source, childSource, ctx, msg, 'manual');
}
}
diff --git a/src/nodeClass.js b/src/nodeClass.js
index 00608ac..8d9215a 100644
--- a/src/nodeClass.js
+++ b/src/nodeClass.js
@@ -75,6 +75,7 @@ class nodeClass {
host: uiConfig.host || 'localhost',
port: Number(uiConfig.port || 3000),
bearerToken,
+ folderTitle: uiConfig.folderTitle || '',
folderUid: uiConfig.folderUid || '',
},
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',
diff --git a/src/specificClass.js b/src/specificClass.js
index f9923b1..33c035d 100644
--- a/src/specificClass.js
+++ b/src/specificClass.js
@@ -86,6 +86,12 @@ class DashboardApi {
host: config?.grafanaConnector?.host || 'localhost',
port: Number(config?.grafanaConnector?.port || 3000),
bearerToken: config?.grafanaConnector?.bearerToken || '',
+ // folderTitle is the durable way to target a folder: Grafana folder
+ // uids change whenever the instance is rebuilt, so a pinned folderUid
+ // goes stale (every upsert then 400s "folder not found"). When set, the
+ // uid is resolved (and the folder created if absent) by name at emit
+ // time. folderUid stays supported as an explicit override / fallback.
+ folderTitle: config?.grafanaConnector?.folderTitle || '',
folderUid: config?.grafanaConnector?.folderUid || '',
},
defaultBucket: config?.defaultBucket || '',
@@ -158,6 +164,76 @@ class DashboardApi {
return `${protocol}://${host}:${port}/api/dashboards/db`;
}
+ grafanaFoldersUrl() {
+ const { protocol, host, port } = this.config.grafanaConnector;
+ return `${protocol}://${host}:${port}/api/folders`;
+ }
+
+ _grafanaJsonHeaders() {
+ const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
+ const token = this.config.grafanaConnector.bearerToken;
+ if (token) headers.Authorization = `Bearer ${token}`;
+ return headers;
+ }
+
+ // Resolve the target Grafana folder uid by NAME, creating the folder if it
+ // doesn't exist. This is the durable alternative to a pinned folderUid, which
+ // goes stale on every Grafana rebuild (the new instance hands the same-named
+ // folder a fresh uid, and every dashboard upsert then 400s "folder not
+ // found"). Resolution is done once per process and cached.
+ //
+ // Degradation contract: any failure (no fetch, network error, non-OK
+ // response) logs a warning and falls back to the configured folderUid, so the
+ // node is never worse off than the pinned-uid behavior it replaces.
+ async resolveFolderUid({ fetchImpl = globalThis.fetch } = {}) {
+ const gc = this.config.grafanaConnector;
+ const title = String(gc.folderTitle || '').trim();
+ // No title configured → legacy behavior: use the explicit uid (may be '').
+ if (!title) return gc.folderUid || '';
+ if (this._resolvedFolderUid) return this._resolvedFolderUid;
+ if (typeof fetchImpl !== 'function') {
+ this.logger.warn('resolveFolderUid: no fetch implementation available; using configured folderUid');
+ return gc.folderUid || '';
+ }
+ try {
+ const uid = await this._lookupOrCreateFolder(title, fetchImpl);
+ if (uid) {
+ this._resolvedFolderUid = uid;
+ return uid;
+ }
+ } catch (err) {
+ this.logger.warn(`resolveFolderUid failed (${err?.message || err}); using configured folderUid`);
+ }
+ return gc.folderUid || '';
+ }
+
+ async _lookupOrCreateFolder(title, fetchImpl) {
+ const url = this.grafanaFoldersUrl();
+ const headers = this._grafanaJsonHeaders();
+
+ const listRes = await fetchImpl(url, { method: 'GET', headers });
+ if (listRes?.ok) {
+ const folders = await listRes.json();
+ const match = Array.isArray(folders)
+ && folders.find((f) => String(f?.title || '').trim().toLowerCase() === title.toLowerCase());
+ if (match?.uid) {
+ this.logger.info({ event: 'folder-resolved', outcome: 'found', title, uid: match.uid });
+ return match.uid;
+ }
+ } else {
+ this.logger.warn(`resolveFolderUid: GET /api/folders -> ${listRes?.status}`);
+ }
+
+ const createRes = await fetchImpl(url, { method: 'POST', headers, body: JSON.stringify({ title }) });
+ if (createRes?.ok) {
+ const created = await createRes.json();
+ this.logger.info({ event: 'folder-resolved', outcome: 'created', title, uid: created?.uid });
+ return created?.uid || '';
+ }
+ this.logger.warn(`resolveFolderUid: POST /api/folders -> ${createRes?.status}`);
+ return '';
+ }
+
buildDashboard({ nodeConfig, positionVsParent }) {
const softwareType =
nodeConfig?.functionality?.softwareType ||
diff --git a/test/_output-manifest.md b/test/_output-manifest.md
index 657e172..769a23c 100644
--- a/test/_output-manifest.md
+++ b/test/_output-manifest.md
@@ -16,7 +16,7 @@ Emitted by the command handler(s) after a `child.register` or `regenerate-dashbo
| `headers.Authorization` | `handlers.emitDashboardsFor` | `'Bearer '` when configured; absent when not | populated, absent (degraded — no token) | `test/basic/slice43-output-manifest.basic.test.js` |
| `payload.dashboard` | `source.buildDashboard()` | object (Grafana dashboard JSON) | populated, byte-identical-on-repeat | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js` |
| `payload.overwrite` | `source.buildUpsertRequest()` | `true` (literal) | populated | `test/basic/slice34-credentials-and-folder.basic.test.js` |
-| `payload.folderUid` | `source.buildUpsertRequest()` | string when configured; absent when empty | populated, absent (degraded — empty config) | `test/basic/slice34-credentials-and-folder.basic.test.js` |
+| `payload.folderUid` | `handlers.emitDashboardsFor` → `source.resolveFolderUid()` (by-name lookup/create, cached; falls back to configured `folderUid`) → `source.buildUpsertRequest()` | resolved uid string when `folderTitle` set or `folderUid` configured; absent when both empty | populated (resolved/found, created, fallback), absent (degraded — empty config) | `test/basic/slice48-folder-resolve-by-name.basic.test.js`, `test/basic/slice34-credentials-and-folder.basic.test.js` |
| `payload.folderId` | `source.buildUpsertRequest()` | number when explicitly passed; absent otherwise | absent (default), populated (explicit) | `test/basic/slice34-credentials-and-folder.basic.test.js` |
| `meta.nodeId` | `handlers.emitDashboardsFor` | string (child node id) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
| `meta.softwareType` | `handlers.emitDashboardsFor` | string (child softwareType) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
diff --git a/test/basic/slice41-manual-regen.basic.test.js b/test/basic/slice41-manual-regen.basic.test.js
index 140e4b2..98fbedd 100644
--- a/test/basic/slice41-manual-regen.basic.test.js
+++ b/test/basic/slice41-manual-regen.basic.test.js
@@ -32,14 +32,14 @@ test('recordChild caches child source by id; subsequent ones replace by id', ()
assert.equal(api.cachedChildSources().length, 2);
});
-test('regenerate-dashboard with no cached children is a no-op (no msgs emitted)', () => {
+test('regenerate-dashboard with no cached children is a no-op (no msgs emitted)', async () => {
const api = new DashboardApi({});
const sends = [];
- handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, makeCtx(sends));
+ await handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, makeCtx(sends));
assert.equal(sends.length, 0);
});
-test('regenerate-dashboard re-emits for each cached child, bypassing diff', () => {
+test('regenerate-dashboard re-emits for each cached child, bypassing diff', async () => {
const api = new DashboardApi({});
// Pre-populate cache as if two children had registered.
api.recordChild(makeChildPayload('m-1'));
@@ -50,18 +50,18 @@ test('regenerate-dashboard re-emits for each cached child, bypassing diff', () =
api.lastFlowsStartedDiff = { added: [], changed: [], removed: [], rewired: [] };
const sends = [];
- handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, makeCtx(sends));
+ await handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, makeCtx(sends));
// Each child yields at least one dashboard message (the root for the child's view).
assert.ok(sends.length >= 2, `expected ≥2 emitted msgs, got ${sends.length}`);
// Every emitted msg carries trigger: 'manual' in meta.
for (const m of sends) assert.equal(m.meta?.trigger, 'manual');
});
-test('child.register stamps trigger: child.register in emitted msg meta', () => {
+test('child.register stamps trigger: child.register in emitted msg meta', async () => {
const api = new DashboardApi({});
api.lastFlowsStartedDiff = null; // cold-start → always regen
const sends = [];
- handlers.registerChild(api, { topic: 'child.register', payload: makeChildPayload('m-3') }, makeCtx(sends));
+ await handlers.registerChild(api, { topic: 'child.register', payload: makeChildPayload('m-3') }, makeCtx(sends));
assert.ok(sends.length >= 1);
for (const m of sends) assert.equal(m.meta?.trigger, 'child.register');
});
diff --git a/test/basic/slice43-output-manifest.basic.test.js b/test/basic/slice43-output-manifest.basic.test.js
index 3bbb67d..18992e2 100644
--- a/test/basic/slice43-output-manifest.basic.test.js
+++ b/test/basic/slice43-output-manifest.basic.test.js
@@ -35,13 +35,13 @@ function makeCtx(nodeId = 'dApi-1') {
}
// ── Port 0 message shape: populated ────────────────────────────────────
-test('Port 0 emit has all required keys when token + folderUid configured', () => {
+test('Port 0 emit has all required keys when token + folderUid configured', async () => {
const api = new DashboardApi({
grafanaConnector: { protocol: 'http', host: 'grafana', port: 3000, bearerToken: 'tok', folderUid: 'rnd-folder' },
});
api.lastFlowsStartedDiff = null; // cold start
const { sends, ctx } = makeCtx();
- handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-1', 'FT-001') }, ctx);
+ await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-1', 'FT-001') }, ctx);
assert.ok(sends.length >= 1);
const m = sends[0];
@@ -63,11 +63,11 @@ test('Port 0 emit has all required keys when token + folderUid configured', () =
});
// ── Port 0 degraded: token absent, folderUid absent ───────────────────
-test('Port 0 emit omits Authorization header when no bearerToken configured', () => {
+test('Port 0 emit omits Authorization header when no bearerToken configured', async () => {
const api = new DashboardApi({}); // no creds
api.lastFlowsStartedDiff = null;
const { sends, ctx } = makeCtx();
- handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-2') }, ctx);
+ await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-2') }, ctx);
const m = sends[0];
assert.equal(m.headers.Authorization, undefined,
'Authorization should be absent (not empty string, not null)');
@@ -78,17 +78,17 @@ test('Port 0 emit omits Authorization header when no bearerToken configured', ()
});
// ── Port 0 degraded: no template for softwareType ─────────────────────
-test('Port 0 emits no message when child softwareType has no template', () => {
+test('Port 0 emits no message when child softwareType has no template', async () => {
const api = new DashboardApi({});
api.lastFlowsStartedDiff = null;
const { sends, ctx } = makeCtx();
// 'nonexistent' has no config/<>.json file
- handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-3', 'm-3', 'nonexistent') }, ctx);
+ await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-3', 'm-3', 'nonexistent') }, ctx);
assert.equal(sends.length, 0, 'no upsert message should be emitted when template missing');
});
// ── Diff-skip path: no emission, logged outcome:no-diff ───────────────
-test('Diff-skip suppresses Port 0 emission AND records the skip in source.logger', () => {
+test('Diff-skip suppresses Port 0 emission AND records the skip in source.logger', async () => {
const api = new DashboardApi({});
// Set diff so the predicate returns false (no overlap with subtree).
api.lastFlowsStartedDiff = { added: ['unrelated'], changed: [], removed: [], rewired: [] };
@@ -97,7 +97,7 @@ test('Diff-skip suppresses Port 0 emission AND records the skip in source.logger
api.logger = { info: (e) => captured.push(e), debug: () => {} };
const { sends, ctx } = makeCtx('dApi-1');
- handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-4') }, ctx);
+ await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-4') }, ctx);
assert.equal(sends.length, 0, 'no upsert emitted when subtree unchanged');
const skipLog = captured.find((e) => e.event === 'regen-skipped');
@@ -109,14 +109,14 @@ test('Diff-skip suppresses Port 0 emission AND records the skip in source.logger
});
// ── Successful regen logs structured fields per N-4 ───────────────────
-test('Successful regen logs event=regen-emitted with N-4 fields', () => {
+test('Successful regen logs event=regen-emitted with N-4 fields', async () => {
const api = new DashboardApi({});
api.lastFlowsStartedDiff = null; // cold start → always regen
const captured = [];
api.logger = { info: (e) => captured.push(e), debug: () => {} };
const { ctx } = makeCtx('dApi-1');
- handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-5') }, ctx);
+ await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-5') }, ctx);
const emitLog = captured.find((e) => e.event === 'regen-emitted');
assert.ok(emitLog, 'regen-emitted log present');
@@ -127,14 +127,14 @@ test('Successful regen logs event=regen-emitted with N-4 fields', () => {
});
// ── Manual regen logs manual-regen-requested + emits with trigger:manual ─
-test('Manual regen logs manual-regen-requested and stamps trigger=manual', () => {
+test('Manual regen logs manual-regen-requested and stamps trigger=manual', async () => {
const api = new DashboardApi({});
api.recordChild(makeChild('m-6'));
const captured = [];
api.logger = { info: (e) => captured.push(e), debug: () => {} };
const { sends, ctx } = makeCtx();
- handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, ctx);
+ await handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, ctx);
const reqLog = captured.find((e) => e.event === 'manual-regen-requested');
assert.ok(reqLog, 'manual-regen-requested log present');
diff --git a/test/basic/slice48-folder-resolve-by-name.basic.test.js b/test/basic/slice48-folder-resolve-by-name.basic.test.js
new file mode 100644
index 0000000..520161d
--- /dev/null
+++ b/test/basic/slice48-folder-resolve-by-name.basic.test.js
@@ -0,0 +1,100 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const DashboardApi = require('../../src/specificClass.js');
+const { registerChild } = require('../../src/commands/handlers.js');
+
+// Minimal fetch double. `routes` maps `${method} ${pathname}` to a response
+// descriptor { ok, status, body }. Records every call for assertions.
+function makeFetch(routes) {
+ const calls = [];
+ const fetchImpl = async (url, opts = {}) => {
+ const method = opts.method || 'GET';
+ const { pathname } = new URL(url);
+ calls.push({ method, pathname, body: opts.body });
+ const r = routes[`${method} ${pathname}`];
+ if (!r) return { ok: false, status: 404, json: async () => ({ message: 'not found' }) };
+ if (typeof r === 'function') return r();
+ return { ok: r.ok ?? true, status: r.status ?? 200, json: async () => r.body };
+ };
+ fetchImpl.calls = calls;
+ return fetchImpl;
+}
+
+function api(grafanaConnector) {
+ return new DashboardApi({ grafanaConnector });
+}
+
+test('no folderTitle → returns configured folderUid without any fetch (legacy path)', async () => {
+ const a = api({ folderUid: 'pinned-uid' });
+ const fetchImpl = makeFetch({});
+ const uid = await a.resolveFolderUid({ fetchImpl });
+ assert.equal(uid, 'pinned-uid');
+ assert.equal(fetchImpl.calls.length, 0, 'must not call Grafana when no folderTitle is set');
+});
+
+test('folderTitle matches an existing folder (case-insensitive) → returns its uid', async () => {
+ const a = api({ folderTitle: 'EVOLV' });
+ const fetchImpl = makeFetch({
+ 'GET /api/folders': { body: [{ title: 'Other', uid: 'x' }, { title: 'evolv', uid: 'bfncls6af0b9cb' }] },
+ });
+ const uid = await a.resolveFolderUid({ fetchImpl });
+ assert.equal(uid, 'bfncls6af0b9cb');
+ assert.equal(fetchImpl.calls.filter((c) => c.method === 'POST').length, 0, 'must not create when found');
+});
+
+test('resolution is cached → second call makes no further fetch', async () => {
+ const a = api({ folderTitle: 'EVOLV' });
+ const fetchImpl = makeFetch({ 'GET /api/folders': { body: [{ title: 'EVOLV', uid: 'u1' }] } });
+ await a.resolveFolderUid({ fetchImpl });
+ await a.resolveFolderUid({ fetchImpl });
+ assert.equal(fetchImpl.calls.length, 1, 'second resolve should hit the cache');
+});
+
+test('folder absent → creates it by name and returns the new uid', async () => {
+ const a = api({ folderTitle: 'EVOLV' });
+ const fetchImpl = makeFetch({
+ 'GET /api/folders': { body: [{ title: 'Other', uid: 'x' }] },
+ 'POST /api/folders': { status: 200, body: { uid: 'created-uid', title: 'EVOLV' } },
+ });
+ const uid = await a.resolveFolderUid({ fetchImpl });
+ assert.equal(uid, 'created-uid');
+ const post = fetchImpl.calls.find((c) => c.method === 'POST');
+ assert.equal(JSON.parse(post.body).title, 'EVOLV');
+});
+
+test('fetch throws → falls back to configured folderUid (never worse than pinned)', async () => {
+ const a = api({ folderTitle: 'EVOLV', folderUid: 'fallback-uid' });
+ const fetchImpl = async () => { throw new Error('ECONNREFUSED'); };
+ const uid = await a.resolveFolderUid({ fetchImpl });
+ assert.equal(uid, 'fallback-uid');
+});
+
+test('no fetch implementation available → falls back to configured folderUid', async () => {
+ const a = api({ folderTitle: 'EVOLV', folderUid: 'fallback-uid' });
+ // Pass an explicit non-function (not undefined, which would trigger the
+ // globalThis.fetch default) to exercise the "no fetch available" branch.
+ const uid = await a.resolveFolderUid({ fetchImpl: null });
+ assert.equal(uid, 'fallback-uid');
+});
+
+test('emit path stamps the resolved folderUid onto every upsert payload', async () => {
+ const a = api({ folderTitle: 'EVOLV' });
+ // Force a deterministic resolution without standing up fetch.
+ a.resolveFolderUid = async () => 'resolved-folder-uid';
+
+ const childSource = {
+ config: { general: { id: 'm1', name: 'Level' }, functionality: { softwareType: 'measurement' } },
+ };
+ const sent = [];
+ const ctx = { node: { id: 'dapi' }, send: (m) => sent.push(m) };
+ await registerChild(a, { payload: childSource }, ctx);
+
+ assert.ok(sent.length >= 1, 'should emit at least one create');
+ for (const m of sent) {
+ assert.equal(m.topic, 'create');
+ assert.equal(m.payload.folderUid, 'resolved-folder-uid');
+ }
+});