- Update all submodule URLs from gitea.centraal.wbd-rd.nl to gitea.wbd-rd.nl - Add settler as proper submodule in .gitmodules - Add agent skills, function anchors, decisions, and improvements - Add Docker configuration and scripts - Add manuals and third_party docs - Update .gitignore with secrets and build artifacts - Remove stale .tgz build artifact Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
584 lines
18 KiB
JavaScript
584 lines
18 KiB
JavaScript
#!/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: `<template>
|
|
<div class="chain-container">
|
|
<svg viewBox="0 0 900 280" class="chain-svg">
|
|
<!-- PS West -->
|
|
<g @click="navigateTo('/ps-west')" class="chain-block clickable">
|
|
<rect x="20" y="20" width="160" height="80" rx="8" :fill="blockColor(merge?.west)"/>
|
|
<text x="100" y="50" class="block-title">PS West</text>
|
|
<text x="100" y="70" class="block-value">{{ formatPct(merge?.west?.fillPct) }}</text>
|
|
<text x="100" y="86" class="block-sub">{{ formatDir(merge?.west?.direction) }}</text>
|
|
</g>
|
|
|
|
<!-- PS North -->
|
|
<g @click="navigateTo('/ps-north')" class="chain-block clickable">
|
|
<rect x="20" y="120" width="160" height="80" rx="8" :fill="blockColor(merge?.north)"/>
|
|
<text x="100" y="150" class="block-title">PS North</text>
|
|
<text x="100" y="170" class="block-value">{{ formatPct(merge?.north?.fillPct) }}</text>
|
|
<text x="100" y="186" class="block-sub">{{ formatDir(merge?.north?.direction) }}</text>
|
|
</g>
|
|
|
|
<!-- PS South -->
|
|
<g @click="navigateTo('/ps-south')" class="chain-block clickable">
|
|
<rect x="20" y="220" width="160" height="80" rx="8" :fill="blockColor(merge?.south)"/>
|
|
<text x="100" y="250" class="block-title">PS South</text>
|
|
<text x="100" y="270" class="block-value">{{ formatPct(merge?.south?.fillPct) }}</text>
|
|
<text x="100" y="286" class="block-sub">{{ formatDir(merge?.south?.direction) }}</text>
|
|
</g>
|
|
|
|
<!-- Merge arrows -->
|
|
<line x1="180" y1="60" x2="260" y2="160" class="chain-arrow"/>
|
|
<line x1="180" y1="160" x2="260" y2="160" class="chain-arrow"/>
|
|
<line x1="180" y1="260" x2="260" y2="160" class="chain-arrow"/>
|
|
|
|
<!-- Merge point -->
|
|
<g class="chain-block">
|
|
<rect x="260" y="120" width="120" height="80" rx="8" fill="#0f3460"/>
|
|
<text x="320" y="150" class="block-title">Merge</text>
|
|
<text x="320" y="170" class="block-value">{{ formatFlow(merge?.totalInfluentFlow) }}</text>
|
|
<text x="320" y="186" class="block-sub">m\\u00b3/h total</text>
|
|
</g>
|
|
|
|
<!-- Arrow merge → reactor -->
|
|
<line x1="380" y1="160" x2="420" y2="160" class="chain-arrow"/>
|
|
|
|
<!-- Reactor -->
|
|
<g @click="navigateTo('/treatment')" class="chain-block clickable">
|
|
<rect x="420" y="120" width="140" height="80" rx="8" :fill="reactorColor"/>
|
|
<text x="490" y="150" class="block-title">Reactor</text>
|
|
<text x="490" y="170" class="block-value">DO: {{ reactorDO }}</text>
|
|
<text x="490" y="186" class="block-sub">mg/L</text>
|
|
</g>
|
|
|
|
<!-- Arrow reactor → settler -->
|
|
<line x1="560" y1="160" x2="600" y2="160" class="chain-arrow"/>
|
|
|
|
<!-- Settler -->
|
|
<g @click="navigateTo('/treatment')" class="chain-block clickable">
|
|
<rect x="600" y="120" width="120" height="80" rx="8" fill="#0f3460"/>
|
|
<text x="660" y="150" class="block-title">Settler</text>
|
|
<text x="660" y="170" class="block-value">TSS: {{ effTSS }}</text>
|
|
<text x="660" y="186" class="block-sub">mg/L</text>
|
|
</g>
|
|
|
|
<!-- Arrow settler → effluent -->
|
|
<line x1="720" y1="160" x2="760" y2="160" class="chain-arrow"/>
|
|
|
|
<!-- Effluent -->
|
|
<g class="chain-block">
|
|
<rect x="760" y="120" width="120" height="80" rx="8" :fill="effluentColor"/>
|
|
<text x="820" y="150" class="block-title">Effluent</text>
|
|
<text x="820" y="170" class="block-value">NH4: {{ effNH4 }}</text>
|
|
<text x="820" y="186" class="block-sub">mg/L</text>
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
export default {
|
|
data() {
|
|
return {
|
|
merge: null,
|
|
reactorDO: '--',
|
|
effTSS: '--',
|
|
effNH4: '--'
|
|
}
|
|
},
|
|
computed: {
|
|
reactorColor() {
|
|
const d = parseFloat(this.reactorDO);
|
|
if (isNaN(d)) return '#0f3460';
|
|
if (d < 1) return '#f44336';
|
|
if (d < 2) return '#ff9800';
|
|
return '#1b5e20';
|
|
},
|
|
effluentColor() {
|
|
const n = parseFloat(this.effNH4);
|
|
if (isNaN(n)) return '#0f3460';
|
|
if (n > 10) return '#f44336';
|
|
if (n > 5) return '#ff9800';
|
|
return '#1b5e20';
|
|
}
|
|
},
|
|
watch: {
|
|
msg(val) {
|
|
if (!val) return;
|
|
const t = val.topic || '';
|
|
if (t === 'overview_chain') {
|
|
this.merge = val.payload;
|
|
} else if (t === 'Reactor DO') {
|
|
this.reactorDO = val.payload?.toFixed(1) || '--';
|
|
} else if (t === 'Effluent TSS') {
|
|
this.effTSS = val.payload?.toFixed(1) || '--';
|
|
} else if (t === 'Effluent NH4') {
|
|
this.effNH4 = val.payload?.toFixed(1) || '--';
|
|
}
|
|
}
|
|
},
|
|
methods: {
|
|
navigateTo(path) {
|
|
this.$router.push('/dashboard' + path);
|
|
},
|
|
blockColor(ps) {
|
|
if (!ps || ps.fillPct === undefined) return '#0f3460';
|
|
if (ps.fillPct > 90) return '#f44336';
|
|
if (ps.fillPct > 75) return '#ff9800';
|
|
if (ps.fillPct < 10) return '#f44336';
|
|
return '#0f3460';
|
|
},
|
|
formatPct(v) { return v !== undefined && v !== null ? v.toFixed(0) + '%' : '--'; },
|
|
formatFlow(v) { return v !== undefined && v !== null ? v.toFixed(0) : '--'; },
|
|
formatDir(d) { return d === 'filling' ? '\\u2191 filling' : d === 'emptying' ? '\\u2193 emptying' : '--'; }
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.chain-container { width: 100%; overflow-x: auto; }
|
|
.chain-svg { width: 100%; height: auto; min-height: 200px; }
|
|
.chain-block text { text-anchor: middle; fill: #e0e0e0; }
|
|
.block-title { font-size: 14px; font-weight: bold; }
|
|
.block-value { font-size: 13px; fill: #4fc3f7; }
|
|
.block-sub { font-size: 10px; fill: #90a4ae; }
|
|
.chain-arrow { stroke: #4fc3f7; stroke-width: 2; marker-end: url(#arrowhead); }
|
|
.clickable { cursor: pointer; }
|
|
.clickable:hover rect { opacity: 0.8; }
|
|
</style>`,
|
|
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}`);
|