'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;