const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const { logger } = require('generalFunctions'); function stableUid(input) { const digest = crypto.createHash('sha1').update(String(input)).digest('hex'); return digest.slice(0, 12); } function slugify(input) { return String(input || '') .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '') .slice(0, 60); } function defaultBucketForPosition(positionVsParent) { const pos = String(positionVsParent || '').toLowerCase(); if (pos === 'upstream') return 'lvl1'; if (pos === 'downstream') return 'lvl3'; return 'lvl2'; } function updateTemplatingVar(dashboard, varName, value) { const list = dashboard?.templating?.list; if (!Array.isArray(list)) return; const variable = list.find((v) => v && v.name === varName); if (!variable) return; variable.current = variable.current || {}; variable.current.text = value; variable.current.value = value; if (Array.isArray(variable.options) && variable.options.length > 0) { variable.options[0] = variable.options[0] || {}; variable.options[0].text = value; variable.options[0].value = value; } variable.query = value; } /** * Dashboard domain service. * Builds Grafana dashboard payloads from EVOLV node config and child topology. */ class DashboardApi { constructor(config = {}) { this.config = { general: { name: config?.general?.name || 'dashboardapi', logging: { enabled: config?.general?.logging?.enabled ?? true, logLevel: config?.general?.logging?.logLevel || 'info', }, }, grafanaConnector: { protocol: config?.grafanaConnector?.protocol || 'http', host: config?.grafanaConnector?.host || 'localhost', port: Number(config?.grafanaConnector?.port || 3000), bearerToken: config?.grafanaConnector?.bearerToken || '', folderUid: config?.grafanaConnector?.folderUid || '', }, defaultBucket: config?.defaultBucket || '', bucketMap: config?.bucketMap || {}, }; this.logger = new logger( this.config.general.logging.enabled, this.config.general.logging.logLevel, this.config.general.name ); } _templatesDir() { return path.join(__dirname, '..', 'config'); } _templateFileForSoftwareType(softwareType) { const st = String(softwareType || '').trim(); const candidates = [ `${st}.json`, `${st.toLowerCase()}.json`, st === 'machineGroupControl' ? 'machineGroup.json' : null, ].filter(Boolean); for (const filename of candidates) { const fullPath = path.join(this._templatesDir(), filename); if (fs.existsSync(fullPath)) return fullPath; } this.logger.warn(`No dashboard template found for softwareType=${st}`); return null; } loadTemplate(softwareType) { const templatePath = this._templateFileForSoftwareType(softwareType); if (!templatePath) return null; const raw = fs.readFileSync(templatePath, 'utf8'); return JSON.parse(raw); } // Collect every `meta.emittedFields` declared by panels in a template. // Used by #39's parent panel filter — a parent panel whose emittedFields // are fully covered by its children's panels is removed. collectEmittedFields(dashboard) { const out = new Set(); for (const panel of dashboard?.panels || []) { const fields = panel?.meta?.emittedFields; if (Array.isArray(fields)) for (const f of fields) out.add(f); } return out; } grafanaUpsertUrl() { const { protocol, host, port } = this.config.grafanaConnector; return `${protocol}://${host}:${port}/api/dashboards/db`; } buildDashboard({ nodeConfig, positionVsParent }) { const softwareType = nodeConfig?.functionality?.softwareType || nodeConfig?.functionality?.software_type || 'measurement'; const nodeId = nodeConfig?.general?.id || nodeConfig?.general?.name || softwareType; const measurementName = `${softwareType}_${nodeId}`; const title = nodeConfig?.general?.name || String(nodeId); // Missing templates are treated as non-fatal: we skip only that dashboard. const dashboard = this.loadTemplate(softwareType); if (!dashboard) { this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`); return null; } const uid = stableUid(`${softwareType}:${nodeId}`); dashboard.id = null; dashboard.uid = uid; dashboard.title = title; dashboard.tags = Array.from( new Set([...(dashboard.tags || []), 'EVOLV', softwareType, String(positionVsParent || '')].filter(Boolean)) ); const bucket = this.config.defaultBucket || this.config.bucketMap[String(positionVsParent)] || defaultBucketForPosition(positionVsParent); updateTemplatingVar(dashboard, 'measurement', measurementName); updateTemplatingVar(dashboard, 'bucket', bucket); return { dashboard, uid, title, softwareType, nodeId, measurementName }; } buildUpsertRequest({ dashboard, folderId, folderUid, overwrite = true }) { const out = { dashboard, overwrite }; // Prefer folderUid (modern Grafana API). Fall back to folderId for older callers. const uid = folderUid ?? this.config?.grafanaConnector?.folderUid ?? ''; if (uid) out.folderUid = uid; else if (typeof folderId === 'number') out.folderId = folderId; return out; } extractChildren(nodeSource) { const out = []; const reg = nodeSource?.childRegistrationUtils?.registeredChildren; if (reg && typeof reg.values === 'function') { for (const entry of reg.values()) { const child = entry?.child; if (!child?.config) continue; out.push({ childSource: child, positionVsParent: entry?.position || child.positionVsParent }); } return out; } return out; } // Predicate from Gitea issue #32 spike (S1 findings). Given the diff payload // from Node-RED's flows:started event and a set of node ids that constitute // "my subtree", decides whether the subtree changed on this deploy. // `null` diff (first deploy / startup) → always regen (safe default). subtreeChanged(diff, subtreeIds) { if (!diff) return true; const mine = new Set(subtreeIds); for (const field of ['added', 'changed', 'removed', 'rewired']) { const arr = diff[field] || []; if (arr.some((id) => mine.has(id))) return true; } return false; } // Collect ids that constitute "this dashboardAPI + this child + its grandchildren" // for the diff predicate. Pulls grandchildren via the existing extractChildren walk. subtreeIdsFor(dashboardApiNodeId, childSource) { const ids = new Set(); if (dashboardApiNodeId) ids.add(dashboardApiNodeId); const childId = childSource?.config?.general?.id; if (childId) ids.add(childId); for (const { childSource: gc } of this.extractChildren(childSource)) { const gcId = gc?.config?.general?.id; if (gcId) ids.add(gcId); } return ids; } generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) { if (!rootSource?.config) { this.logger.warn('generateDashboardsForGraph skipped: root source missing config'); return []; } const rootPosition = rootSource?.positionVsParent || rootSource?.config?.functionality?.positionVsParent; const rootDash = this.buildDashboard({ nodeConfig: rootSource.config, positionVsParent: rootPosition }); if (!rootDash) return []; const results = [rootDash]; if (!includeChildren) return results; const children = this.extractChildren(rootSource); for (const { childSource, positionVsParent } of children) { const childDash = this.buildDashboard({ nodeConfig: childSource.config, positionVsParent }); if (childDash) results.push(childDash); } // Add links from the root dashboard to children dashboards (when possible) if (children.length > 0) { rootDash.dashboard.links = Array.isArray(rootDash.dashboard.links) ? rootDash.dashboard.links : []; for (const { childSource } of children) { const childConfig = childSource.config; const childSoftwareType = childConfig?.functionality?.softwareType || 'measurement'; const childNodeId = childConfig?.general?.id || childConfig?.general?.name || childSoftwareType; const childUid = stableUid(`${childSoftwareType}:${childNodeId}`); const childTitle = childConfig?.general?.name || String(childNodeId); rootDash.dashboard.links.push({ type: 'link', title: childTitle, url: `/d/${childUid}/${slugify(childTitle)}`, tags: [], targetBlank: false, keepTime: true, keepVariables: true, }); } } return results; } } module.exports = DashboardApi;