diff --git a/src/commands/handlers.js b/src/commands/handlers.js index 0a70aa6..9d44f4f 100644 --- a/src/commands/handlers.js +++ b/src/commands/handlers.js @@ -22,35 +22,9 @@ function resolveChildNode(childId, ctx) { return runtimeNode || flowNode || null; } -// On child.register: build the dashboard graph (root + direct children) and -// emit one Grafana upsert HTTP request per dashboard on Port 0. -// -// Diff-skip behavior (PRD F-1, S1 spike #32): if the latest flows:started -// 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) { - const childSource = resolveChildSource(msg.payload, ctx); - if (!childSource?.config) { - throw new Error('Missing or invalid child node'); - } - - const subtreeIds = source.subtreeIdsFor(ctx.node?.id, childSource); - const changed = source.subtreeChanged(source.lastFlowsStartedDiff, subtreeIds); - if (!changed) { - if (source.logger?.info) { - source.logger.info({ - event: 'regen-skipped', - outcome: 'no-diff', - trigger: 'child.register', - dashboardApiId: ctx.node?.id, - childId: childSource?.config?.general?.id, - subtreeSize: subtreeIds.size, - }); - } - return; - } - +// 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) { const dashboards = source.generateDashboardsForGraph(childSource, { includeChildren: Boolean(msg.includeChildren ?? true), }); @@ -77,9 +51,73 @@ function registerChild(source, msg, ctx) { softwareType: dash.softwareType, uid: dash.uid, title: dash.title, + trigger, }, }); } + + if (source.logger?.info) { + source.logger.info({ + event: 'regen-emitted', + trigger, + dashboardApiId: ctx.node?.id, + childId: childSource?.config?.general?.id, + dashboardCount: dashboards.length, + }); + } } -module.exports = { registerChild }; +// On child.register: build the dashboard graph (root + direct children) and +// emit one Grafana upsert HTTP request per dashboard on Port 0. +// +// Diff-skip behavior (PRD F-1, S1 spike #32): if the latest flows:started +// 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) { + const childSource = resolveChildSource(msg.payload, ctx); + if (!childSource?.config) { + throw new Error('Missing or invalid child node'); + } + + // Cache the child source for later manual regen (#41). + source.recordChild?.(childSource); + + const subtreeIds = source.subtreeIdsFor(ctx.node?.id, childSource); + const changed = source.subtreeChanged(source.lastFlowsStartedDiff, subtreeIds); + if (!changed) { + if (source.logger?.info) { + source.logger.info({ + event: 'regen-skipped', + outcome: 'no-diff', + trigger: 'child.register', + dashboardApiId: ctx.node?.id, + childId: childSource?.config?.general?.id, + subtreeSize: subtreeIds.size, + }); + } + return; + } + + 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) { + const cached = source.cachedChildSources?.() || []; + if (source.logger?.info) { + source.logger.info({ + event: 'manual-regen-requested', + trigger: 'manual', + dashboardApiId: ctx.node?.id, + cachedChildCount: cached.length, + }); + } + for (const childSource of cached) { + emitDashboardsFor(source, childSource, ctx, msg, 'manual'); + } +} + +module.exports = { registerChild, regenerateDashboard }; diff --git a/src/commands/index.js b/src/commands/index.js index 5bef2e6..ad8696a 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -13,4 +13,10 @@ module.exports = [ payloadSchema: { type: 'any' }, handler: handlers.registerChild, }, + { + topic: 'regenerate-dashboard', + aliases: ['regen'], + payloadSchema: { type: 'any' }, + handler: handlers.regenerateDashboard, + }, ]; diff --git a/src/specificClass.js b/src/specificClass.js index d04139d..4f72a33 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -75,6 +75,20 @@ class DashboardApi { this.config.general.logging.logLevel, this.config.general.name ); + + // Light state cache for manual regen (#41). Stores the latest child + // source object per child id so `regenerate-dashboard` can re-emit + // dashboards without waiting for children to re-register. + this._lastChildSources = new Map(); + } + + recordChild(childSource) { + const id = childSource?.config?.general?.id; + if (id) this._lastChildSources.set(id, childSource); + } + + cachedChildSources() { + return Array.from(this._lastChildSources.values()); } _templatesDir() { diff --git a/test/basic/slice41-manual-regen.basic.test.js b/test/basic/slice41-manual-regen.basic.test.js new file mode 100644 index 0000000..140e4b2 --- /dev/null +++ b/test/basic/slice41-manual-regen.basic.test.js @@ -0,0 +1,75 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const DashboardApi = require('../../src/specificClass.js'); +const handlers = require('../../src/commands/handlers.js'); + +function makeCtx(sends, nodeId = 'dApi-1') { + return { + node: { id: nodeId }, + RED: { nodes: { getNode: () => null } }, + send: (m) => sends.push(m), + logger: null, + }; +} + +function makeChildPayload(id, softwareType = 'measurement') { + return { + config: { + general: { id, name: id }, + functionality: { softwareType, positionVsParent: 'downstream' }, + }, + }; +} + +test('recordChild caches child source by id; subsequent ones replace by id', () => { + const api = new DashboardApi({}); + api.recordChild(makeChildPayload('a')); + api.recordChild(makeChildPayload('b')); + api.recordChild(makeChildPayload('a')); // replace + assert.equal(api.cachedChildSources().length, 2); +}); + +test('regenerate-dashboard with no cached children is a no-op (no msgs emitted)', () => { + const api = new DashboardApi({}); + const sends = []; + 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', () => { + const api = new DashboardApi({}); + // Pre-populate cache as if two children had registered. + api.recordChild(makeChildPayload('m-1')); + api.recordChild(makeChildPayload('m-2')); + + // Set a diff that says nothing changed — registerChild would skip, but + // regenerateDashboard should ignore the predicate. + api.lastFlowsStartedDiff = { added: [], changed: [], removed: [], rewired: [] }; + + const sends = []; + 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', () => { + 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)); + assert.ok(sends.length >= 1); + for (const m of sends) assert.equal(m.meta?.trigger, 'child.register'); +}); + +test('command registry exposes regenerate-dashboard with regen alias', () => { + const registry = require('../../src/commands/index.js'); + const entry = registry.find((e) => e.topic === 'regenerate-dashboard'); + assert.ok(entry, 'topic registered'); + assert.deepEqual(entry.aliases, ['regen']); + assert.equal(typeof entry.handler, 'function'); +});