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
114 lines
4.0 KiB
JavaScript
114 lines
4.0 KiB
JavaScript
'use strict';
|
|
|
|
// dashboardAPI nodeClass — passive HTTP-emitter adapter.
|
|
//
|
|
// Does NOT extend BaseNodeAdapter: dashboardAPI has no generalFunctions
|
|
// config JSON, no Port-0/1 telemetry stream, no parent registration, no
|
|
// tick or status loop. It just listens for `child.register` and emits one
|
|
// Grafana upsert HTTP request per dashboard. See OPEN_QUESTIONS.md
|
|
// (2026-05-10) for the rationale.
|
|
|
|
const { configManager, createRegistry } = require('generalFunctions');
|
|
const DashboardApi = require('./specificClass');
|
|
const commands = require('./commands');
|
|
|
|
class nodeClass {
|
|
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
|
this.node = nodeInstance;
|
|
this.RED = RED;
|
|
this.name = nameOfNode;
|
|
|
|
this.config = this._buildConfig(uiConfig);
|
|
this.source = new DashboardApi(this.config);
|
|
this.node.source = this.source;
|
|
|
|
this._commands = createRegistry(commands, { logger: this.source?.logger });
|
|
|
|
this._attachInputHandler();
|
|
this._attachCloseHandler();
|
|
this._attachLifecycleHook();
|
|
}
|
|
|
|
// Subscribe to Node-RED's `flows:started` event to cache the deploy diff so
|
|
// the child.register handler can decide whether *this* dashboardAPI's
|
|
// subtree was affected. Predicate documented in Gitea issue #32 spike.
|
|
_attachLifecycleHook() {
|
|
if (!this.RED?.events?.on) return;
|
|
this._flowsStartedListener = (payload) => {
|
|
const diff = payload?.diff || null;
|
|
this.source.lastFlowsStartedDiff = diff;
|
|
this.source.lastFlowsStartedAt = Date.now();
|
|
if (this.source?.logger?.debug) {
|
|
const summary = diff
|
|
? Object.fromEntries(
|
|
['added', 'changed', 'removed', 'rewired', 'linked', 'flowChanged']
|
|
.map((k) => [k, (diff[k] || []).length])
|
|
)
|
|
: null;
|
|
this.source.logger.debug({ event: 'flows:started', type: payload?.type, diff: summary });
|
|
}
|
|
};
|
|
this.RED.events.on('flows:started', this._flowsStartedListener);
|
|
}
|
|
|
|
_buildConfig(uiConfig) {
|
|
const cfgMgr = new configManager();
|
|
// Credentials block (Node-RED encrypts at rest in flow_cred.json). Legacy
|
|
// installs may still carry bearerToken on uiConfig — fall back with a
|
|
// one-time deprecation warning so the user knows to re-save.
|
|
const credentialToken = this.node?.credentials?.bearerToken || '';
|
|
const legacyToken = uiConfig.bearerToken || '';
|
|
if (!credentialToken && legacyToken) {
|
|
this.RED?.log?.warn?.(
|
|
`[${this.name}] bearer token loaded from legacy plain config field. ` +
|
|
`Re-open this node in the editor and click Done to migrate to encrypted credentials.`
|
|
);
|
|
}
|
|
const bearerToken = credentialToken || legacyToken;
|
|
return cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
|
|
functionality: {
|
|
softwareType: this.name.toLowerCase(),
|
|
role: 'auto ui generator',
|
|
},
|
|
grafanaConnector: {
|
|
protocol: uiConfig.protocol || 'http',
|
|
host: uiConfig.host || 'localhost',
|
|
port: Number(uiConfig.port || 3000),
|
|
bearerToken,
|
|
folderUid: uiConfig.folderUid || '',
|
|
},
|
|
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',
|
|
});
|
|
}
|
|
|
|
_attachInputHandler() {
|
|
this.node.on('input', async (msg, send, done) => {
|
|
try {
|
|
await this._commands.dispatch(msg, this.source, {
|
|
node: this.node,
|
|
RED: this.RED,
|
|
send,
|
|
logger: this.source?.logger,
|
|
});
|
|
if (typeof done === 'function') done();
|
|
} catch (error) {
|
|
this.node.status({ fill: 'red', shape: 'ring', text: 'dashboardapi error' });
|
|
this.node.error(error?.message || error, msg);
|
|
if (typeof done === 'function') done(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
_attachCloseHandler() {
|
|
this.node.on('close', (done) => {
|
|
if (this._flowsStartedListener && this.RED?.events?.off) {
|
|
this.RED.events.off('flows:started', this._flowsStartedListener);
|
|
this._flowsStartedListener = null;
|
|
}
|
|
if (typeof done === 'function') done();
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = nodeClass;
|