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