UNPKG

@janart19/node-red-fusebox

Version:

A comprehensive collection of custom nodes for interfacing with Fusebox automation controllers - data streams, energy management, and utilities

691 lines (613 loc) 30 kB
/** * Flow Analyzer Node * Deep analysis of Node-RED flows to detect loops, issues, and optimization opportunities */ module.exports = function (RED) { "use strict"; function FlowAnalyzerNode(config) { RED.nodes.createNode(this, config); const node = this; node.autoRunOnDeploy = config.autoRunOnDeploy !== false; // Default true node.showInStatus = config.showInStatus !== false; // Default true node.analyzeAllFlows = config.analyzeAllFlows !== false; // Default true // Status display function setStatus(text, shape, fill) { if (node.showInStatus) { node.status({ fill: fill, shape: shape, text: text }); } } setStatus("Ready", "dot", "grey"); /** * Main analysis function */ function analyzeFlows() { const startTime = Date.now(); const results = { timestamp: new Date().toISOString(), summary: { totalNodes: 0, totalFlows: 0, issues: 0, warnings: 0, info: 0 }, loops: [], topicConflicts: [], disconnectedNodes: [], linkChains: [], performanceRisks: [], details: [] }; try { // Build flow map const flowMap = buildFlowMap(); results.summary.totalNodes = flowMap._totalNodeCount || 0; results.summary.totalFlows = Object.keys(flowMap).length; // Run all checks detectFeedbackLoops(flowMap, results); detectTopicConflicts(flowMap, results); detectDisconnectedNodes(flowMap, results); analyzeLinkChains(flowMap, results); detectPerformanceRisks(flowMap, results); // Calculate summary results.summary.issues = results.loops.length + results.topicConflicts.filter((c) => c.severity === "error").length; results.summary.warnings = results.topicConflicts.filter((c) => c.severity === "warning").length + results.disconnectedNodes.length + results.performanceRisks.length; results.summary.info = results.linkChains.length; const elapsed = Date.now() - startTime; results.analysisTime = `${elapsed}ms`; // Update status if (results.summary.issues > 0) { setStatus(`${results.summary.issues} issues found`, "dot", "red"); } else if (results.summary.warnings > 0) { setStatus(`${results.summary.warnings} warnings`, "dot", "yellow"); } else { setStatus(`Clean (${elapsed}ms)`, "dot", "green"); } // Log summary node.log(`Flow analysis complete: ${results.summary.issues} issues, ${results.summary.warnings} warnings (${elapsed}ms)`); // Print detailed summary report to log printSummaryReport(results); // Send results node.send({ payload: results, topic: "flow-analysis" }); return results; } catch (err) { node.error(`Flow analysis failed: ${err.message}`, err); setStatus("Analysis failed", "dot", "red"); return null; } } /** * Build a map of all flows and nodes */ function buildFlowMap() { const flowMap = {}; let totalNodeCount = 0; RED.nodes.eachNode((n) => { totalNodeCount++; if (!flowMap[n.z]) { flowMap[n.z] = { id: n.z, nodes: [], writeNodes: [], readNodes: [], linkOutNodes: [], linkInNodes: [] }; } flowMap[n.z].nodes.push(n); // Categorize special nodes if (n.type === "fusebox-write-data-streams") { flowMap[n.z].writeNodes.push(n); } else if (n.type === "fusebox-read-data-streams") { flowMap[n.z].readNodes.push(n); } else if (n.type === "link out") { flowMap[n.z].linkOutNodes.push(n); } else if (n.type === "link in") { flowMap[n.z].linkInNodes.push(n); } }); // Count total nodes Object.keys(flowMap).forEach((flowId) => { const flow = flowMap[flowId]; flow.nodeCount = flow.nodes.length; }); // Store total count using a non-enumerable property to avoid iteration issues Object.defineProperty(flowMap, "_totalNodeCount", { value: totalNodeCount, enumerable: false, writable: false }); return flowMap; } /** * Detect actual feedback loops by following wires */ function detectFeedbackLoops(flowMap, results) { const visited = new Set(); const recStack = new Set(); const paths = new Map(); // Check each write node Object.values(flowMap).forEach((flow) => { flow.writeNodes.forEach((writeNode) => { visited.clear(); recStack.clear(); paths.clear(); const loop = detectLoopFromNode(writeNode, writeNode, [], flowMap, visited, recStack); if (loop) { results.loops.push({ type: "feedback-loop", severity: "error", path: loop.path, pathIds: loop.pathIds, description: `Feedback loop detected: ${loop.path.join(" → ")}`, startNode: writeNode.id, startNodeName: writeNode.name || writeNode.type }); } }); }); } /** * Recursive loop detection following wires */ function detectLoopFromNode(currentNode, targetNode, path, flowMap, visited, recStack) { if (!currentNode) return null; const nodeId = currentNode.id; const nodeName = currentNode.name || currentNode.type; // Found a loop back to target if (path.length > 0 && nodeId === targetNode.id) { return { path: [...path, nodeName], pathIds: [...path.map((p) => p.id), nodeId] }; } // Already in recursion stack = loop detected if (recStack.has(nodeId)) { return null; } // Mark as visiting recStack.add(nodeId); path.push({ name: nodeName, id: nodeId }); // Follow wires if (currentNode.wires && Array.isArray(currentNode.wires)) { for (const wireArray of currentNode.wires) { if (Array.isArray(wireArray)) { for (const targetId of wireArray) { const nextNode = RED.nodes.getNode(targetId); if (nextNode) { // Handle link-out nodes specially if (nextNode.type === "link out") { const linkedNodes = findLinkedInNodes(nextNode, flowMap); for (const linkedNode of linkedNodes) { const result = detectLoopFromNode(linkedNode, targetNode, [...path], flowMap, visited, recStack); if (result) return result; } } else { const result = detectLoopFromNode(nextNode, targetNode, [...path], flowMap, visited, recStack); if (result) return result; } } } } } } // Unmark from recursion stack recStack.delete(nodeId); path.pop(); return null; } /** * Find link-in nodes connected to a link-out node */ function findLinkedInNodes(linkOutNode, flowMap) { const linkedNodes = []; const linkOutLinks = linkOutNode.links || []; Object.values(flowMap).forEach((flow) => { flow.linkInNodes.forEach((linkInNode) => { if (linkOutLinks.includes(linkInNode.id)) { linkedNodes.push(linkInNode); } }); }); return linkedNodes; } /** * Detect topic conflicts between read/write nodes */ function detectTopicConflicts(flowMap, results) { const writeTopics = new Map(); // topic -> [nodes] const readTopics = new Map(); // topic -> [nodes] // Collect all write topics Object.values(flowMap).forEach((flow) => { flow.writeNodes.forEach((writeNode) => { const topics = extractWriteTopics(writeNode); // Deduplicate topics from same node (e.g., HV2W.1, HV2W.2 both map to HV2W) const uniqueTopics = [...new Set(topics)]; uniqueTopics.forEach((topic) => { if (!writeTopics.has(topic)) { writeTopics.set(topic, []); } // Check if this node is already in the list for this topic const existing = writeTopics.get(topic).find((n) => n.node.id === writeNode.id); if (!existing) { writeTopics.get(topic).push({ node: writeNode, flowId: flow.id }); } }); }); flow.readNodes.forEach((readNode) => { const topics = extractReadTopics(readNode); // Deduplicate topics from same node const uniqueTopics = [...new Set(topics)]; uniqueTopics.forEach((topic) => { if (!readTopics.has(topic)) { readTopics.set(topic, []); } // Check if this node is already in the list for this topic const existing = readTopics.get(topic).find((n) => n.node.id === readNode.id); if (!existing) { readTopics.get(topic).push({ node: readNode, flowId: flow.id }); } }); }); }); // Find overlaps writeTopics.forEach((writers, topic) => { if (readTopics.has(topic)) { const readers = readTopics.get(topic); // Check if there's actual wiring between them let hasWiring = false; for (const writer of writers) { for (const reader of readers) { if (isConnected(writer.node, reader.node, flowMap)) { hasWiring = true; break; } } if (hasWiring) break; } results.topicConflicts.push({ topic: topic, severity: hasWiring ? "error" : "warning", writers: writers.map((w) => ({ id: w.node.id, name: w.node.name || w.node.type, flow: w.flowId })), readers: readers.map((r) => ({ id: r.node.id, name: r.node.name || r.node.type, flow: r.flowId })), description: hasWiring ? `CONFIRMED LOOP: Topic "${topic}" has ${writers.length} writer(s) and ${readers.length} reader(s) with actual wiring connection` : `Topic "${topic}" has ${writers.length} writer(s) and ${readers.length} reader(s) (no direct wiring detected)`, hasWiring: hasWiring }); } // Multiple writers to same topic if (writers.length > 1) { results.topicConflicts.push({ topic: topic, severity: "warning", writers: writers.map((w) => ({ id: w.node.id, name: w.node.name || w.node.type, flow: w.flowId })), description: `Multiple nodes (${writers.length}) writing to topic "${topic}" - potential conflict` }); } }); } /** * Check if two nodes are connected via wiring */ function isConnected(sourceNode, targetNode, flowMap, visited = new Set()) { if (!sourceNode || !targetNode) return false; if (sourceNode.id === targetNode.id) return true; if (visited.has(sourceNode.id)) return false; visited.add(sourceNode.id); // Follow wires if (sourceNode.wires && Array.isArray(sourceNode.wires)) { for (const wireArray of sourceNode.wires) { if (Array.isArray(wireArray)) { for (const targetId of wireArray) { if (targetId === targetNode.id) return true; const nextNode = RED.nodes.getNode(targetId); if (nextNode) { // Handle link-out nodes if (nextNode.type === "link out") { const linkedNodes = findLinkedInNodes(nextNode, flowMap); for (const linkedNode of linkedNodes) { if (isConnected(linkedNode, targetNode, flowMap, visited)) { return true; } } } else { if (isConnected(nextNode, targetNode, flowMap, visited)) { return true; } } } } } } } return false; } /** * Extract topics with member indices from write node config */ function extractWriteTopics(writeNode) { const topics = []; if (writeNode.mappings && Array.isArray(writeNode.mappings)) { writeNode.mappings.forEach((mapping) => { const key = mapping.keyNameSelect || mapping.keyNameManual; const member = mapping.memberIndex || mapping.memberIndexManual || "1"; if (key) { // Include member index for accurate conflict detection topics.push(`${key}.${member}`); } }); } return topics; } /** * Extract topics with member indices from read node config */ function extractReadTopics(readNode) { const topics = []; if (readNode.mappings && Array.isArray(readNode.mappings)) { readNode.mappings.forEach((mapping) => { const key = mapping.keyNameSelect || mapping.keyNameManual; const member = mapping.memberIndex || mapping.memberIndexManual || "1"; if (key) { // Include member index for accurate conflict detection topics.push(`${key}.${member}`); } }); } return topics; } /** * Detect disconnected nodes */ function detectDisconnectedNodes(flowMap, results) { Object.values(flowMap).forEach((flow) => { flow.nodes.forEach((n) => { const hasInputs = hasIncomingWires(n, flow); const hasOutputs = n.wires && n.wires.length > 0 && n.wires.some((w) => w && w.length > 0); // Skip certain node types that don't need connections const skipTypes = [ "comment", "inject", "debug", "link in", "link out", "catch", "status", "tab", "group", "subflow", // Structural nodes "fusebox-controller", "ui-base", "ui-page", "ui-group", "ui-theme", // Config nodes "mqtt-broker", "http request" // Other config-like nodes ]; if (skipTypes.includes(n.type)) return; if (!hasInputs && !hasOutputs) { results.disconnectedNodes.push({ id: n.id, name: n.name || n.type, type: n.type, flow: flow.id, description: `Node "${n.name || n.type}" has no connections` }); } }); }); } /** * Check if node has incoming wires */ function hasIncomingWires(targetNode, flow) { return flow.nodes.some((n) => { if (!n.wires) return false; return n.wires.some((wireArray) => { return wireArray && wireArray.includes(targetNode.id); }); }); } /** * Print a formatted summary report to the log */ function printSummaryReport(results) { const lines = []; lines.push(""); lines.push("╔══════════════════════════════════════════════════════════════════════╗"); lines.push("║ FLOW ANALYSIS SUMMARY REPORT ║"); lines.push("╚══════════════════════════════════════════════════════════════════════╝"); lines.push(""); // Summary stats lines.push(`📊 Overview:`); lines.push(` Total Nodes: ${results.summary.totalNodes}`); lines.push(` Total Flows: ${results.summary.totalFlows}`); lines.push(` Analysis Time: ${results.analysisTime}`); lines.push(""); // Issues (red) if (results.summary.issues > 0) { lines.push(`🔴 ISSUES: ${results.summary.issues}`); results.loops.forEach((loop, i) => { lines.push(` ${i + 1}. Feedback Loop: ${loop.path.join(" → ")}`); }); results.topicConflicts .filter((c) => c.severity === "error") .forEach((conf, i) => { if (conf.hasWiring) { lines.push(` ${results.loops.length + i + 1}. CONFIRMED LOOP: "${conf.topic}" has wiring connection`); lines.push(` Writers: ${conf.writers.map((w) => w.name).join(", ")}`); lines.push(` Readers: ${conf.readers.map((r) => r.name).join(", ")}`); } }); lines.push(""); } else { lines.push(`✅ ISSUES: None`); lines.push(""); } // Warnings (yellow) const topicWarnings = results.topicConflicts.filter((c) => c.severity === "warning" && c.readers); const multiWriters = results.topicConflicts.filter((c) => !c.readers && c.writers.length > 1); if (results.summary.warnings > 0) { lines.push(`⚠️ WARNINGS: ${results.summary.warnings}`); // Topic conflicts (no wiring) if (topicWarnings.length > 0) { lines.push(` 📡 Topic Overlaps (no direct wiring):`); topicWarnings.forEach((conf) => { lines.push(` • "${conf.topic}": ${conf.writers.length} writer(s) + ${conf.readers.length} reader(s)`); }); } // Multiple writers if (multiWriters.length > 0) { lines.push(` ✍️ Multiple Writers to Same Topic+Member:`); multiWriters.forEach((conf) => { lines.push(` • "${conf.topic}": ${conf.writers.length} writers`); }); } // Disconnected nodes if (results.disconnectedNodes.length > 0) { lines.push(` 🔌 Disconnected Nodes: ${results.disconnectedNodes.length}`); results.disconnectedNodes.forEach((node) => { lines.push(` • ${node.name} (${node.type})`); }); } // Performance risks if (results.performanceRisks.length > 0) { lines.push(` ⚡ Performance Risks: ${results.performanceRisks.length}`); results.performanceRisks.forEach((risk) => { if (risk.type === "large-flow") { lines.push(` • Large flow: ${risk.nodeCount} nodes`); } else if (risk.type === "rapid-inject") { lines.push(` • Rapid inject: "${risk.node.name}" every ${risk.interval}s`); } }); } lines.push(""); } else { lines.push(`✅ WARNINGS: None`); lines.push(""); } // Info (blue) if (results.summary.info > 0) { lines.push(`ℹ️ INFO: ${results.summary.info} link chains found`); lines.push(` (Link chains = WIRED functional dependencies via link-out → link-in nodes)`); lines.push(` (These are REAL data flows, not just topic name matches)`); const crossTabLinks = results.linkChains.filter((l) => l.crossTab); if (crossTabLinks.length > 0) { lines.push(` 🔗 Cross-tab links: ${crossTabLinks.length} (data flowing between different tabs)`); } lines.push(""); } // Recommendations if (results.summary.issues > 0 || results.summary.warnings > 0) { lines.push(`💡 Recommendations:`); if (results.summary.issues > 0) { lines.push(` 1. Fix confirmed loops first (enable "Gate loop" or break wiring)`); } if (topicWarnings.length > 0) { lines.push(` 2. Topic+Member overlaps shown are at MEMBER level (e.g., HV2W.1 vs HV2W.1)`); lines.push(` - If different members (HV2W.1 vs HV2W.2), no conflict!`); lines.push(` - Overlaps are safe if nodes aren't directly wired together`); } if (results.disconnectedNodes.length > 0) { lines.push(` 3. Remove or connect disconnected nodes`); } lines.push(""); } lines.push("────────────────────────────────────────────────────────────────────────"); lines.push(""); lines.push("ℹ️ Note: This analyzer checks WIRING structure. For TOPIC orphan detection"); lines.push(" (topics published but not consumed, or vice versa), use flow-validator node."); lines.push(" Both analyzers are complementary and serve different purposes."); lines.push(""); // Log all lines lines.forEach((line) => node.warn(line)); } /** * Analyze link node chains */ function analyzeLinkChains(flowMap, results) { const linkChains = new Map(); Object.values(flowMap).forEach((flow) => { flow.linkOutNodes.forEach((linkOut) => { const linkedIns = findLinkedInNodes(linkOut, flowMap); if (linkedIns.length > 0) { linkChains.set(linkOut.id, { linkOut: { id: linkOut.id, name: linkOut.name, flow: flow.id }, linkIns: linkedIns.map((li) => ({ id: li.id, name: li.name, flow: li.z })), crossTab: linkedIns.some((li) => li.z !== linkOut.z) }); } }); }); linkChains.forEach((chain, id) => { results.linkChains.push({ linkOut: chain.linkOut, linkIns: chain.linkIns, crossTab: chain.crossTab, description: `Link "${chain.linkOut.name}" connects to ${chain.linkIns.length} link-in node(s)${chain.crossTab ? " (cross-tab)" : ""}` }); }); } /** * Detect performance risks */ function detectPerformanceRisks(flowMap, results) { Object.values(flowMap).forEach((flow) => { // Too many nodes in one flow if (flow.nodeCount > 100) { results.performanceRisks.push({ type: "large-flow", severity: "warning", flow: flow.id, nodeCount: flow.nodeCount, description: `Flow has ${flow.nodeCount} nodes - consider splitting for better performance` }); } // Check for rapid loops (inject with short interval) flow.nodes.forEach((n) => { if (n.type === "inject" && n.repeat && parseFloat(n.repeat) < 0.5) { results.performanceRisks.push({ type: "rapid-inject", severity: "warning", node: { id: n.id, name: n.name || n.type }, interval: n.repeat, description: `Inject node "${n.name || n.type}" runs every ${n.repeat}s - may cause high CPU usage` }); } }); }); } // Handle input messages (manual trigger) node.on("input", function (msg) { node.log("Manual flow analysis triggered"); analyzeFlows(); }); // Auto-run on deploy (via deploy event detection) if (node.autoRunOnDeploy) { // Use a short delay to ensure all nodes are registered setTimeout(() => { node.log("Auto-running flow analysis on deploy"); analyzeFlows(); }, 1000); } node.on("close", function () { setStatus("", "dot", "grey"); }); } RED.nodes.registerType("fusebox-flow-analyzer", FlowAnalyzerNode); };