'use strict'; // Resolve a child's source object from a registration payload. // Payload may be: a string (node id) | { source: {...} } | { config: {...} }. function resolveChildSource(payload, ctx) { if (payload?.source?.config) return payload.source; if (payload?.config) return { config: payload.config }; if (typeof payload === 'string') { const childNode = resolveChildNode(payload, ctx); return childNode?.source || null; } return null; } function resolveChildNode(childId, ctx) { const runtimeNode = ctx.RED?.nodes?.getNode?.(childId); if (runtimeNode?.source?.config) return runtimeNode; const flowNode = ctx.node?._flow?.getNode?.(childId); if (flowNode?.source?.config) return flowNode; return runtimeNode || flowNode || null; } // Shared emit path used by both child.register (auto, deploy-driven) and // regenerate-dashboard (manual). `trigger` distinguishes the two for logs. async function emitDashboardsFor(source, childSource, ctx, msg, trigger) { const dashboards = source.generateDashboardsForGraph(childSource, { includeChildren: Boolean(msg.includeChildren ?? true), }); const url = source.grafanaUpsertUrl(); const headers = { Accept: 'application/json', 'Content-Type': 'application/json' }; 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, topic: 'create', url, method: 'POST', headers, payload: source.buildUpsertRequest({ dashboard: dash.dashboard, folderUid: folderUid || undefined, overwrite: true, }), meta: { nodeId: dash.nodeId, 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, }); } } // 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. async 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; } 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. async 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) { await emitDashboardsFor(source, childSource, ctx, msg, 'manual'); } } module.exports = { registerChild, regenerateDashboard };