#!/usr/bin/env node /** * Step 3: Overview Dashboard Page + KPI Gauges * - Creates overview page with chain visualization * - Adds KPI gauges (Total Flow, DO, TSS, NH4) * - Link-in nodes to feed overview from merge + reactor + effluent data * - Reorders all page navigation */ const fs = require('fs'); const path = require('path'); const FLOW_PATH = path.join(__dirname, '..', 'docker', 'demo-flow.json'); const flow = JSON.parse(fs.readFileSync(FLOW_PATH, 'utf8')); const byId = (id) => flow.find(n => n.id === id); // ============================================= // 3a. New config nodes // ============================================= // Overview page flow.push({ id: "demo_ui_page_overview", type: "ui-page", name: "Plant Overview", ui: "demo_ui_base", path: "/overview", icon: "dashboard", layout: "grid", theme: "demo_ui_theme", breakpoints: [{ name: "Default", px: "0", cols: "12" }], order: 0, className: "" }); // Overview groups flow.push( { id: "demo_ui_grp_overview_chain", type: "ui-group", name: "Process Chain", page: "demo_ui_page_overview", width: "12", height: "1", order: 1, showTitle: true, className: "" }, { id: "demo_ui_grp_overview_kpi", type: "ui-group", name: "Key Indicators", page: "demo_ui_page_overview", width: "12", height: "1", order: 2, showTitle: true, className: "" } ); // ============================================= // 3b. Chain visualization - link-in nodes on dashboard tab // ============================================= // Link-in for merge data (this is what step 2's demo_link_merge_dash links to) flow.push({ id: "demo_link_merge_dash_in", type: "link in", z: "demo_tab_dashboard", name: "← Merge Data", links: ["demo_link_merge_dash"], x: 75, y: 960, wires: [["demo_fn_overview_parse"]] }); // We also need reactor and effluent data for the overview. // Create link-out nodes on treatment tab for overview data flow.push( { id: "demo_link_overview_reactor_out", type: "link out", z: "demo_tab_treatment", name: "→ Overview (Reactor)", mode: "link", links: ["demo_link_overview_reactor_in"], x: 1020, y: 220 }, { id: "demo_link_overview_reactor_in", type: "link in", z: "demo_tab_dashboard", name: "← Reactor (Overview)", links: ["demo_link_overview_reactor_out"], x: 75, y: 1020, wires: [["demo_fn_overview_reactor_parse"]] } ); // Add overview reactor link-out to reactor's wires[0] const reactor = byId("demo_reactor"); reactor.wires[0].push("demo_link_overview_reactor_out"); // Effluent measurements link for overview KPIs flow.push( { id: "demo_link_overview_eff_out", type: "link out", z: "demo_tab_treatment", name: "→ Overview (Effluent)", mode: "link", links: ["demo_link_overview_eff_in"], x: 620, y: 660 }, { id: "demo_link_overview_eff_in", type: "link in", z: "demo_tab_dashboard", name: "← Effluent (Overview)", links: ["demo_link_overview_eff_out"], x: 75, y: 1080, wires: [["demo_fn_overview_eff_parse"]] } ); // Add overview eff link-out to effluent measurement nodes wires[0] // TSS and NH4 are the key effluent quality indicators const effTss = byId("demo_meas_eff_tss"); effTss.wires[0].push("demo_link_overview_eff_out"); const effNh4 = byId("demo_meas_eff_nh4"); effNh4.wires[0].push("demo_link_overview_eff_out"); // ============================================= // 3b. Parse functions for overview // ============================================= // Parse merge data for chain visualization + total flow gauge flow.push({ id: "demo_fn_overview_parse", type: "function", z: "demo_tab_dashboard", name: "Parse Overview (Merge)", func: `const p = msg.payload || {}; const now = Date.now(); // Store in flow context for the template flow.set('overview_merge', p); // Output 1: chain vis data, Output 2: total flow gauge return [ { topic: 'overview_chain', payload: p }, p.totalInfluentFlow !== undefined ? { topic: 'Total Influent Flow', payload: p.totalInfluentFlow } : null ];`, outputs: 2, x: 280, y: 960, wires: [ ["demo_overview_template"], ["demo_gauge_overview_flow"] ] }); // Parse reactor data for overview flow.push({ id: "demo_fn_overview_reactor_parse", type: "function", z: "demo_tab_dashboard", name: "Parse Overview (Reactor)", func: `const p = msg.payload || {}; if (!p.C || !Array.isArray(p.C)) return null; flow.set('overview_reactor', p); // Output: DO gauge value return { topic: 'Reactor DO', payload: Math.round(p.C[0]*100)/100 };`, outputs: 1, x: 280, y: 1020, wires: [["demo_gauge_overview_do"]] }); // Parse effluent data for overview KPIs flow.push({ id: "demo_fn_overview_eff_parse", type: "function", z: "demo_tab_dashboard", name: "Parse Overview (Effluent)", func: `const p = msg.payload || {}; const topic = msg.topic || ''; const val = Number(p.mAbs); if (!Number.isFinite(val)) return null; // Route to appropriate gauge based on measurement type if (topic.includes('TSS') || topic.includes('tss')) { return [{ topic: 'Effluent TSS', payload: Math.round(val*100)/100 }, null]; } if (topic.includes('NH4') || topic.includes('ammonium')) { return [null, { topic: 'Effluent NH4', payload: Math.round(val*100)/100 }]; } return [null, null];`, outputs: 2, x: 280, y: 1080, wires: [ ["demo_gauge_overview_tss"], ["demo_gauge_overview_nh4"] ] }); // ============================================= // 3b. Chain visualization template // ============================================= flow.push({ id: "demo_overview_template", type: "ui-template", z: "demo_tab_dashboard", group: "demo_ui_grp_overview_chain", name: "Process Chain Diagram", order: 1, width: "12", height: "6", head: "", format: ` `, templateScope: "local", className: "", x: 510, y: 960, wires: [[]] }); // ============================================= // 3c. KPI gauges on overview // ============================================= // Total Influent Flow gauge flow.push({ id: "demo_gauge_overview_flow", type: "ui-gauge", z: "demo_tab_dashboard", group: "demo_ui_grp_overview_kpi", name: "Total Influent Flow", gtype: "gauge-34", gstyle: "Rounded", title: "Influent Flow", units: "m\u00b3/h", prefix: "", suffix: "m\u00b3/h", min: 0, max: 500, segments: [ { color: "#2196f3", from: 0 }, { color: "#4caf50", from: 50 }, { color: "#ff9800", from: 350 }, { color: "#f44336", from: 450 } ], width: 3, height: 4, order: 1, className: "", x: 510, y: 1020, wires: [] }); // Reactor DO gauge flow.push({ id: "demo_gauge_overview_do", type: "ui-gauge", z: "demo_tab_dashboard", group: "demo_ui_grp_overview_kpi", name: "Reactor DO", gtype: "gauge-34", gstyle: "Rounded", title: "Reactor DO", units: "mg/L", prefix: "", suffix: "mg/L", min: 0, max: 10, segments: [ { color: "#f44336", from: 0 }, { color: "#ff9800", from: 1 }, { color: "#4caf50", from: 2 }, { color: "#ff9800", from: 6 }, { color: "#f44336", from: 8 } ], width: 3, height: 4, order: 2, className: "", x: 510, y: 1060, wires: [] }); // Effluent TSS gauge flow.push({ id: "demo_gauge_overview_tss", type: "ui-gauge", z: "demo_tab_dashboard", group: "demo_ui_grp_overview_kpi", name: "Effluent TSS", gtype: "gauge-34", gstyle: "Rounded", title: "Effluent TSS", units: "mg/L", prefix: "", suffix: "mg/L", min: 0, max: 50, segments: [ { color: "#4caf50", from: 0 }, { color: "#ff9800", from: 25 }, { color: "#f44336", from: 40 } ], width: 3, height: 4, order: 3, className: "", x: 510, y: 1100, wires: [] }); // Effluent NH4 gauge flow.push({ id: "demo_gauge_overview_nh4", type: "ui-gauge", z: "demo_tab_dashboard", group: "demo_ui_grp_overview_kpi", name: "Effluent NH4", gtype: "gauge-34", gstyle: "Rounded", title: "Effluent NH4", units: "mg/L", prefix: "", suffix: "mg/L", min: 0, max: 20, segments: [ { color: "#4caf50", from: 0 }, { color: "#ff9800", from: 5 }, { color: "#f44336", from: 10 } ], width: 3, height: 4, order: 4, className: "", x: 510, y: 1140, wires: [] }); // ============================================= // 3d. Reorder all page navigation // ============================================= const pageOrders = { "demo_ui_page_overview": 0, "demo_ui_page_influent": 1, "demo_ui_page_treatment": 5, "demo_ui_page_telemetry": 6, }; for (const [pageId, order] of Object.entries(pageOrders)) { const page = byId(pageId); if (page) page.order = order; } // ============================================= // Feed chain vis and KPIs from merge + reactor + effluent // We need to also wire the overview_template to receive reactor/eff data // The parse functions already wire to the template and gauges separately // But the template needs ALL data sources - let's connect reactor and eff parsers to it too // ============================================= // Actually, the template needs multiple inputs. Let's connect reactor and eff parse outputs too. // Modify overview reactor parse to also send to template const reactorParse = byId("demo_fn_overview_reactor_parse"); // Currently wires to demo_gauge_overview_do. Add template as well. reactorParse.func = `const p = msg.payload || {}; if (!p.C || !Array.isArray(p.C)) return null; flow.set('overview_reactor', p); // Output 1: DO gauge, Output 2: to chain template const doVal = Math.round(p.C[0]*100)/100; return [ { topic: 'Reactor DO', payload: doVal }, { topic: 'Reactor DO', payload: doVal } ];`; reactorParse.outputs = 2; reactorParse.wires = [["demo_gauge_overview_do"], ["demo_overview_template"]]; // Same for effluent parse - add template output const effParse = byId("demo_fn_overview_eff_parse"); effParse.func = `const p = msg.payload || {}; const topic = msg.topic || ''; const val = Number(p.mAbs); if (!Number.isFinite(val)) return null; const rounded = Math.round(val*100)/100; // Route to appropriate gauge + template based on measurement type if (topic.includes('TSS') || topic.includes('tss')) { return [{ topic: 'Effluent TSS', payload: rounded }, null, { topic: 'Effluent TSS', payload: rounded }]; } if (topic.includes('NH4') || topic.includes('ammonium')) { return [null, { topic: 'Effluent NH4', payload: rounded }, { topic: 'Effluent NH4', payload: rounded }]; } return [null, null, null];`; effParse.outputs = 3; effParse.wires = [["demo_gauge_overview_tss"], ["demo_gauge_overview_nh4"], ["demo_overview_template"]]; // ============================================= // Validate // ============================================= const allIds = new Set(flow.map(n => n.id)); let issues = 0; for (const n of flow) { if (!n.wires) continue; for (const port of n.wires) { for (const target of port) { if (!allIds.has(target)) { console.warn(`BROKEN WIRE: ${n.id} → ${target}`); issues++; } } } if (n.type === 'link out' && n.links) { for (const lt of n.links) { if (!allIds.has(lt)) { console.warn(`BROKEN LINK OUT: ${n.id} → ${lt}`); issues++; } } } if (n.type === 'link in' && n.links) { for (const ls of n.links) { if (!allIds.has(ls)) { console.warn(`BROKEN LINK IN: ${n.id} ← ${ls}`); issues++; } } } } if (issues === 0) console.log('All references valid ✓'); console.log('Total nodes:', flow.length); // Write fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n'); console.log(`Wrote ${FLOW_PATH}`);