feat(dashboardAPI): resolve Grafana folder by name (fixes stale folderUid 400s)
A pinned folderUid goes stale whenever Grafana is rebuilt — the same-named folder returns with a fresh uid and every dashboard upsert then 400s "folder not found", silently dropping all generated dashboards. Add a folderTitle config field: when set, resolveFolderUid() looks the folder up by name (GET /api/folders), creates it if absent (POST /api/folders), caches the uid for the process, and falls back to the configured folderUid on any failure (never worse than the pinned behavior). The emit handlers (registerChild/regenerateDashboard/emitDashboardsFor) are now async and await the resolution. folderUid retained as an explicit override/fallback. Locked by slice48-folder-resolve-by-name; existing emit tests made async. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
100
test/basic/slice48-folder-resolve-by-name.basic.test.js
Normal file
100
test/basic/slice48-folder-resolve-by-name.basic.test.js
Normal file
@@ -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');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user