2026-05-10 22:23:45 +02:00
|
|
|
'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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// On child.register: build the dashboard graph (root + direct children) and
|
|
|
|
|
// emit one Grafana upsert HTTP request per dashboard on Port 0.
|
feat(dashboardapi): diff-skip regen via flows:started predicate (#36)
Subscribes to Node-RED's flows:started runtime event, caches the {diff}
payload on the dashboardAPI source, and short-circuits the child.register
handler when none of {dashboardAPI id, child id, grandchild ids} appears in
diff.added/changed/removed/rewired. Predicate verified by S1 spike (#32).
- src/nodeClass.js: _attachLifecycleHook subscribes, _attachCloseHandler
cleans up. No-op when RED.events isn't available (unit-test friendly).
- src/specificClass.js: subtreeChanged() predicate + subtreeIdsFor() helper.
- src/commands/handlers.js: registerChild consults predicate before composing;
logs {event:'regen-skipped', outcome:'no-diff'} when skipping.
- test/basic/slice36-diff-predicate.basic.test.js: 8 cases — null/empty diff,
affected/unaffected ids, tab-id over-triggering avoidance, grandchild
inclusion, no-grandchild case.
Cold start (no cached diff yet) always regenerates — safe default. Edge case
documented in #32: when a brand-new child is wired to a registered parent,
the new child's id is in diff.added but not yet in registeredChildren when
flows:started fires. Mitigation (b) per spike findings: one-deploy race
accepted for R&D — next deploy picks up the new registration.
Closes #36
2026-05-26 17:57:34 +02:00
|
|
|
//
|
|
|
|
|
// 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.
|
2026-05-10 22:23:45 +02:00
|
|
|
function registerChild(source, msg, ctx) {
|
|
|
|
|
const childSource = resolveChildSource(msg.payload, ctx);
|
|
|
|
|
if (!childSource?.config) {
|
|
|
|
|
throw new Error('Missing or invalid child node');
|
|
|
|
|
}
|
|
|
|
|
|
feat(dashboardapi): diff-skip regen via flows:started predicate (#36)
Subscribes to Node-RED's flows:started runtime event, caches the {diff}
payload on the dashboardAPI source, and short-circuits the child.register
handler when none of {dashboardAPI id, child id, grandchild ids} appears in
diff.added/changed/removed/rewired. Predicate verified by S1 spike (#32).
- src/nodeClass.js: _attachLifecycleHook subscribes, _attachCloseHandler
cleans up. No-op when RED.events isn't available (unit-test friendly).
- src/specificClass.js: subtreeChanged() predicate + subtreeIdsFor() helper.
- src/commands/handlers.js: registerChild consults predicate before composing;
logs {event:'regen-skipped', outcome:'no-diff'} when skipping.
- test/basic/slice36-diff-predicate.basic.test.js: 8 cases — null/empty diff,
affected/unaffected ids, tab-id over-triggering avoidance, grandchild
inclusion, no-grandchild case.
Cold start (no cached diff yet) always regenerates — safe default. Edge case
documented in #32: when a brand-new child is wired to a registered parent,
the new child's id is in diff.added but not yet in registeredChildren when
flows:started fires. Mitigation (b) per spike findings: one-deploy race
accepted for R&D — next deploy picks up the new registration.
Closes #36
2026-05-26 17:57:34 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 22:23:45 +02:00
|
|
|
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}`;
|
|
|
|
|
|
|
|
|
|
for (const dash of dashboards) {
|
|
|
|
|
ctx.send({
|
|
|
|
|
...msg,
|
|
|
|
|
topic: 'create',
|
|
|
|
|
url,
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers,
|
|
|
|
|
payload: source.buildUpsertRequest({
|
|
|
|
|
dashboard: dash.dashboard,
|
2026-05-26 17:53:42 +02:00
|
|
|
folderUid: source.config?.grafanaConnector?.folderUid || undefined,
|
2026-05-10 22:23:45 +02:00
|
|
|
overwrite: true,
|
|
|
|
|
}),
|
|
|
|
|
meta: {
|
|
|
|
|
nodeId: dash.nodeId,
|
|
|
|
|
softwareType: dash.softwareType,
|
|
|
|
|
uid: dash.uid,
|
|
|
|
|
title: dash.title,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = { registerChild };
|