From 3529c9f9701df669b84affe689fe109e8ef3e719 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Thu, 28 May 2026 10:32:52 +0200 Subject: [PATCH] feat(dashboardAPI): basin canvas + bar gauge for pumpingStation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the configuration row's Heights + Volume Limits stat panels and the radial Fill % gauge with an integrated basin visual that conveys tank geometry and live water level at a glance. Configuration row → Basin row: - Vertical bar gauge bound to level (m) with min=0/max=basinHeight and thresholds at outflow/dryRun/inflow/highSafety/overflow safety levels. - Canvas panel with tank outline, zone tints (dead/operating/highSafety/ spill), threshold lines + named labels, and live numeric readouts for each threshold value plus current level/volume/fill at the bottom. - Level + Volume timeseries moved next to the basin visual so the row reads as basin → trends left-to-right. Other layout polish: - Status row Fill % gauge removed; remaining 4 stats widen to w:6 each. - Old "Basin" row header dropped (its panels migrated into the new row). - Configuration row renamed to "Basin". Mechanics: - dashboardAPI substitutes mustache {{var}} placeholders in templates at JSON.parse time. Per-softwareType var sets live in _templateVarsForNode; pumpingStation gets basin geometry + derived safety levels + canvas pixel y-positions + min-gap-enforced label positions. - Mustache braces stay distinct from Grafana's ${var} dashboard variables. - Canvas Flux query pivots heights + predicted level/volume/percent into one row with normalized field names so metric-value elements can bind. No node-side telemetry change: dryRunLevel + highVolumeSafetyLevel already reach Influx via getOutput() (specificClass.js:248,250) and outputUtils iterates every key with no filter. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/pumpingStation.json | 840 ++++++++++++++++--------------------- package.json | 2 +- src/specificClass.js | 102 ++++- 3 files changed, 472 insertions(+), 472 deletions(-) diff --git a/config/pumpingStation.json b/config/pumpingStation.json index e834185..e8bf747 100644 --- a/config/pumpingStation.json +++ b/config/pumpingStation.json @@ -21,49 +21,26 @@ "links": [], "panels": [ { - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Status", "type": "row" }, { - "datasource": { - "type": "influxdb", - "uid": "cdzg44tv250jkd" - }, + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", - "steps": [ - { - "color": "blue", - "value": null - } - ] + "steps": [{ "color": "blue", "value": null }] } }, "overrides": [] }, - "gridPos": { - "h": 4, - "w": 5, - "x": 0, - "y": 1 - }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 }, "id": 2, "options": { - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "/.*/" - }, + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "/.*/" }, "colorMode": "value", "graphMode": "none" }, @@ -75,53 +52,28 @@ ], "title": "Direction", "type": "stat", - "meta": { - "emittedFields": [ - "direction" - ] - } + "meta": { "emittedFields": ["direction"] } }, { - "datasource": { - "type": "influxdb", - "uid": "cdzg44tv250jkd" - }, + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "orange", - "value": 300 - }, - { - "color": "red", - "value": 600 - } + { "color": "green", "value": null }, + { "color": "orange", "value": 300 }, + { "color": "red", "value": 600 } ] } }, "overrides": [] }, - "gridPos": { - "h": 4, - "w": 5, - "x": 5, - "y": 1 - }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 }, "id": 3, "options": { - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - }, + "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" }, @@ -133,45 +85,23 @@ ], "title": "Time Left", "type": "stat", - "meta": { - "emittedFields": [ - "timeLeft" - ] - } + "meta": { "emittedFields": ["timeLeft"] } }, { - "datasource": { - "type": "influxdb", - "uid": "cdzg44tv250jkd" - }, + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", - "steps": [ - { - "color": "purple", - "value": null - } - ] + "steps": [{ "color": "purple", "value": null }] } }, "overrides": [] }, - "gridPos": { - "h": 4, - "w": 4, - "x": 10, - "y": 1 - }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 }, "id": 4, "options": { - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "/.*/" - }, + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "/.*/" }, "colorMode": "value", "graphMode": "none" }, @@ -183,172 +113,370 @@ ], "title": "Flow Source", "type": "stat", - "meta": { - "emittedFields": [ - "flowSource" - ] - } + "meta": { "emittedFields": ["flowSource"] } }, { - "datasource": { - "type": "influxdb", - "uid": "cdzg44tv250jkd" - }, - "fieldConfig": { - "defaults": { - "min": 0, - "max": 100, - "unit": "percent", - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "red", - "value": null - }, - { - "color": "orange", - "value": 20 - }, - { - "color": "green", - "value": 40 - }, - { - "color": "orange", - "value": 80 - }, - { - "color": "red", - "value": 95 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 5, - "x": 14, - "y": 1 - }, - "id": 5, - "options": { - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "showThresholdLabels": false, - "showThresholdMarkers": true - }, - "targets": [ - { - "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^volumePercent\\.predicted\\.atequipment/)\n |> group(columns:[\"_field\"])\n |> last()", - "refId": "A" - } - ], - "title": "Fill %", - "type": "gauge", - "meta": { - "emittedFields": [ - "volumePercent" - ] - } - }, - { - "datasource": { - "type": "influxdb", - "uid": "cdzg44tv250jkd" - }, + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "fieldConfig": { "defaults": { "unit": "lengthm", "thresholds": { "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] + "steps": [{ "color": "green", "value": null }] } }, "overrides": [] }, - "gridPos": { - "h": 4, - "w": 5, - "x": 19, - "y": 1 - }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 }, "id": 6, "options": { - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - }, + "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" }, "targets": [ { - "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.predicted\\.atequipment/)\n |> group(columns:[\"_field\"])\n |> last()", + "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.predicted\\.atequipment/)\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])", "refId": "A" } ], "title": "Level", "type": "stat", - "meta": { - "emittedFields": [ - "level" - ] - } + "meta": { "emittedFields": ["level"] } }, { - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 5 - }, - "id": 7, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 13, "title": "Basin", "type": "row" }, { - "datasource": { - "type": "influxdb", - "uid": "cdzg44tv250jkd" - }, + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "fieldConfig": { "defaults": { "unit": "lengthm", - "custom": { - "drawStyle": "line", - "lineWidth": 2, - "fillOpacity": 10 + "min": 0, + "max": {{heightBasin}}, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "#3a3a3a", "value": null }, + { "color": "semi-dark-grey", "value": {{outflowLevel}} }, + { "color": "blue", "value": {{dryRunLevel}} }, + { "color": "green", "value": {{inflowLevel}} }, + { "color": "orange", "value": {{highSafetyLevel}} }, + { "color": "red", "value": {{overflowLevel}} } + ] } }, "overrides": [] }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 6 + "gridPos": { "h": 10, "w": 4, "x": 0, "y": 6 }, + "id": 16, + "options": { + "displayMode": "basic", + "orientation": "vertical", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "" }, + "showThresholdLabels": true, + "showThresholdMarkers": true, + "showUnfilled": true, + "minVizWidth": 8, + "minVizHeight": 16, + "valueMode": "color", + "namePlacement": "auto" }, + "targets": [ + { + "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.predicted\\.atequipment/)\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])", + "refId": "A" + } + ], + "title": "Water Level", + "type": "bargauge", + "meta": { "emittedFields": ["basinLevel"] } + }, + { + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "fieldConfig": { + "defaults": { + "unit": "none", + "decimals": 2 + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": "^(outflowLevel|inflowLevel|overflowLevel|heightBasin|dryRunLevel|highVolumeSafetyLevel|level)$" }, + "properties": [{ "id": "unit", "value": "lengthm" }] + }, + { + "matcher": { "id": "byRegexp", "options": "^(volume|maxVol|minVol|maxVolAtOverflow|minVolAtOutflow|minVolAtInflow)$" }, + "properties": [{ "id": "unit", "value": "m3" }] + }, + { + "matcher": { "id": "byRegexp", "options": "Percent$" }, + "properties": [{ "id": "unit", "value": "percent" }] + } + ] + }, + "gridPos": { "h": 10, "w": 8, "x": 4, "y": 6 }, + "id": 17, + "options": { + "inlineEditing": false, + "showAdvancedTypes": true, + "panZoom": false, + "infinitePan": false, + "root": { + "name": "Basin", + "type": "frame", + "placement": { "left": 0, "top": 0, "width": 480, "height": 300 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "dark-green" } }, + "elements": [ + { + "name": "Zone Spill", + "type": "rectangle", + "placement": { "top": 20, "left": 80, "width": 200, "height": {{h_spill}} }, + "background": { "color": { "fixed": "rgba(229, 67, 67, 0.18)" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "" } } + }, + { + "name": "Zone HighSafety", + "type": "rectangle", + "placement": { "top": {{y_overflow}}, "left": 80, "width": 200, "height": {{h_highSafety}} }, + "background": { "color": { "fixed": "rgba(242, 165, 67, 0.16)" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "" } } + }, + { + "name": "Zone Operating", + "type": "rectangle", + "placement": { "top": {{y_highSafety}}, "left": 80, "width": 200, "height": {{h_operating}} }, + "background": { "color": { "fixed": "rgba(95, 179, 122, 0.14)" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "" } } + }, + { + "name": "Zone Dead", + "type": "rectangle", + "placement": { "top": {{y_outflow}}, "left": 80, "width": 200, "height": {{h_dead}} }, + "background": { "color": { "fixed": "rgba(128, 128, 128, 0.20)" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "" } } + }, + { + "name": "Tank Outline", + "type": "rectangle", + "placement": { "top": 20, "left": 80, "width": 200, "height": 240 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "#8a8a8a" }, "width": 2 }, + "config": { "text": { "mode": "fixed", "fixed": "" } } + }, + { + "name": "Line Overflow", + "type": "rectangle", + "placement": { "top": {{y_overflow}}, "left": 80, "width": 200, "height": 1 }, + "background": { "color": { "fixed": "#e54343" } }, + "border": { "color": { "fixed": "#e54343" }, "width": 0 } + }, + { + "name": "Line HighSafety", + "type": "rectangle", + "placement": { "top": {{y_highSafety}}, "left": 80, "width": 200, "height": 1 }, + "background": { "color": { "fixed": "#f2a543" } }, + "border": { "color": { "fixed": "#f2a543" }, "width": 0 } + }, + { + "name": "Line Inflow", + "type": "rectangle", + "placement": { "top": {{y_inflow}}, "left": 80, "width": 200, "height": 1 }, + "background": { "color": { "fixed": "#5fb37a" } }, + "border": { "color": { "fixed": "#5fb37a" }, "width": 0 } + }, + { + "name": "Line DryRun", + "type": "rectangle", + "placement": { "top": {{y_dryRun}}, "left": 80, "width": 200, "height": 1 }, + "background": { "color": { "fixed": "#5b9bd5" } }, + "border": { "color": { "fixed": "#5b9bd5" }, "width": 0 } + }, + { + "name": "Line Outflow", + "type": "rectangle", + "placement": { "top": {{y_outflow}}, "left": 80, "width": 200, "height": 1 }, + "background": { "color": { "fixed": "#bfbfbf" } }, + "border": { "color": { "fixed": "#bfbfbf" }, "width": 0 } + }, + { + "name": "Label Overflow Name", + "type": "text", + "placement": { "top": {{ty_overflow}}, "left": 4, "width": 76, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "overflowLevel" }, "color": { "fixed": "#e54343" }, "size": 11, "align": "right", "valign": "middle" } + }, + { + "name": "Label HighSafety Name", + "type": "text", + "placement": { "top": {{ty_highSafety}}, "left": 0, "width": 80, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "highSafe" }, "color": { "fixed": "#f2a543" }, "size": 11, "align": "right", "valign": "middle" } + }, + { + "name": "Label Inflow Name", + "type": "text", + "placement": { "top": {{ty_inflow}}, "left": 4, "width": 76, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "inflowLevel" }, "color": { "fixed": "#5fb37a" }, "size": 11, "align": "right", "valign": "middle" } + }, + { + "name": "Label DryRun Name", + "type": "text", + "placement": { "top": {{ty_dryRun}}, "left": 4, "width": 76, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "dryRunLevel" }, "color": { "fixed": "#5b9bd5" }, "size": 11, "align": "right", "valign": "middle" } + }, + { + "name": "Label Outflow Name", + "type": "text", + "placement": { "top": {{ty_outflow}}, "left": 4, "width": 76, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "outflowLevel" }, "color": { "fixed": "#bfbfbf" }, "size": 11, "align": "right", "valign": "middle" } + }, + { + "name": "Value Overflow", + "type": "metric-value", + "placement": { "top": {{ty_overflow}}, "left": 286, "width": 90, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "field", "fixed": "", "field": "overflowLevel" }, "color": { "fixed": "#e54343" }, "size": 11, "align": "left", "valign": "middle" } + }, + { + "name": "Value HighSafety", + "type": "metric-value", + "placement": { "top": {{ty_highSafety}}, "left": 286, "width": 90, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "field", "fixed": "", "field": "highVolumeSafetyLevel" }, "color": { "fixed": "#f2a543" }, "size": 11, "align": "left", "valign": "middle" } + }, + { + "name": "Value Inflow", + "type": "metric-value", + "placement": { "top": {{ty_inflow}}, "left": 286, "width": 90, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "field", "fixed": "", "field": "inflowLevel" }, "color": { "fixed": "#5fb37a" }, "size": 11, "align": "left", "valign": "middle" } + }, + { + "name": "Value DryRun", + "type": "metric-value", + "placement": { "top": {{ty_dryRun}}, "left": 286, "width": 90, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "field", "fixed": "", "field": "dryRunLevel" }, "color": { "fixed": "#5b9bd5" }, "size": 11, "align": "left", "valign": "middle" } + }, + { + "name": "Value Outflow", + "type": "metric-value", + "placement": { "top": {{ty_outflow}}, "left": 286, "width": 90, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "field", "fixed": "", "field": "outflowLevel" }, "color": { "fixed": "#bfbfbf" }, "size": 11, "align": "left", "valign": "middle" } + }, + { + "name": "Header Rim", + "type": "text", + "placement": { "top": 2, "left": 80, "width": 200, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "rim ({{heightBasin}} m)" }, "color": { "fixed": "#8a8a8a" }, "size": 10, "align": "center", "valign": "middle" } + }, + { + "name": "Footer Floor", + "type": "text", + "placement": { "top": 262, "left": 80, "width": 200, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "floor (0.00 m)" }, "color": { "fixed": "#8a8a8a" }, "size": 10, "align": "center", "valign": "middle" } + }, + { + "name": "Readout Level Label", + "type": "text", + "placement": { "top": 280, "left": 8, "width": 70, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "level" }, "color": { "fixed": "#888888" }, "size": 10, "align": "right", "valign": "middle" } + }, + { + "name": "Readout Level", + "type": "metric-value", + "placement": { "top": 280, "left": 82, "width": 86, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "field", "fixed": "", "field": "level" }, "color": { "fixed": "#1a1a1a" }, "size": 12, "align": "left", "valign": "middle" } + }, + { + "name": "Readout Volume Label", + "type": "text", + "placement": { "top": 280, "left": 168, "width": 60, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "volume" }, "color": { "fixed": "#888888" }, "size": 10, "align": "right", "valign": "middle" } + }, + { + "name": "Readout Volume", + "type": "metric-value", + "placement": { "top": 280, "left": 232, "width": 70, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "field", "fixed": "", "field": "volume" }, "color": { "fixed": "#1a1a1a" }, "size": 12, "align": "left", "valign": "middle" } + }, + { + "name": "Readout Fill Label", + "type": "text", + "placement": { "top": 280, "left": 302, "width": 50, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "fixed", "fixed": "fill" }, "color": { "fixed": "#888888" }, "size": 10, "align": "right", "valign": "middle" } + }, + { + "name": "Readout Fill", + "type": "metric-value", + "placement": { "top": 280, "left": 356, "width": 70, "height": 16 }, + "background": { "color": { "fixed": "transparent" } }, + "border": { "color": { "fixed": "transparent" }, "width": 0 }, + "config": { "text": { "mode": "field", "fixed": "", "field": "volumePercent" }, "color": { "fixed": "#1a1a1a" }, "size": 12, "align": "left", "valign": "middle" } + } + ] + } + }, + "targets": [ + { + "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"outflowLevel\" or r._field==\"inflowLevel\" or r._field==\"overflowLevel\" or r._field==\"heightBasin\" or r._field==\"dryRunLevel\" or r._field==\"highVolumeSafetyLevel\" or r._field =~ /^level\\.predicted\\.atequipment/ or r._field =~ /^volume\\.predicted\\.atequipment/ or r._field =~ /^volumePercent\\.predicted\\.atequipment/))\n |> last()\n |> map(fn: (r) => ({ r with _field: if r._field =~ /^volumePercent\\.predicted/ then \"volumePercent\" else if r._field =~ /^volume\\.predicted/ then \"volume\" else if r._field =~ /^level\\.predicted/ then \"level\" else r._field, _time: 2020-01-01T00:00:00Z }))\n |> group()\n |> keep(columns:[\"_field\",\"_value\",\"_time\"])\n |> pivot(rowKey:[\"_time\"], columnKey:[\"_field\"], valueColumn:\"_value\")", + "refId": "A" + } + ], + "title": "Tank Layout", + "type": "canvas", + "meta": { "emittedFields": ["basinLayout"] } + }, + { + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, + "fieldConfig": { + "defaults": { + "unit": "lengthm", + "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } + }, + "overrides": [] + }, + "gridPos": { "h": 5, "w": 12, "x": 12, "y": 6 }, "id": 8, "options": { - "legend": { - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } + "legend": { "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "multi" } }, "targets": [ { @@ -356,45 +484,24 @@ "refId": "A" } ], - "title": "Level", + "title": "Level (over time)", "type": "timeseries", - "meta": { - "emittedFields": [ - "level" - ] - } + "meta": { "emittedFields": ["level"] } }, { - "datasource": { - "type": "influxdb", - "uid": "cdzg44tv250jkd" - }, + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "fieldConfig": { "defaults": { "unit": "m3", - "custom": { - "drawStyle": "line", - "lineWidth": 2, - "fillOpacity": 10 - } + "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 6 - }, + "gridPos": { "h": 5, "w": 12, "x": 12, "y": 11 }, "id": 9, "options": { - "legend": { - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } + "legend": { "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "multi" } }, "targets": [ { @@ -402,56 +509,30 @@ "refId": "A" } ], - "title": "Volume", + "title": "Volume (over time)", "type": "timeseries", - "meta": { - "emittedFields": [ - "volume" - ] - } + "meta": { "emittedFields": ["volume"] } }, { - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 14 - }, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 16 }, "id": 10, "title": "Flow", "type": "row" }, { - "datasource": { - "type": "influxdb", - "uid": "cdzg44tv250jkd" - }, + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "fieldConfig": { "defaults": { "unit": "m3/h", - "custom": { - "drawStyle": "line", - "lineWidth": 2, - "fillOpacity": 10 - } + "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 15 - }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 }, "id": 11, "options": { - "legend": { - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } + "legend": { "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "multi" } }, "targets": [ { @@ -461,44 +542,22 @@ ], "title": "Net Flow Rate", "type": "timeseries", - "meta": { - "emittedFields": [ - "flow.net", - "flow" - ] - } + "meta": { "emittedFields": ["flow.net", "flow"] } }, { - "datasource": { - "type": "influxdb", - "uid": "cdzg44tv250jkd" - }, + "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "fieldConfig": { "defaults": { "unit": "m3/h", - "custom": { - "drawStyle": "line", - "lineWidth": 2, - "fillOpacity": 10 - } + "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 15 - }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 }, "id": 12, "options": { - "legend": { - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } + "legend": { "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "multi" } }, "targets": [ { @@ -508,133 +567,11 @@ ], "title": "Inflow + Outflow", "type": "timeseries", - "meta": { - "emittedFields": [ - "flow.in", - "flow.out" - ] - } - }, - { - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 23 - }, - "id": 13, - "title": "Configuration", - "type": "row" - }, - { - "datasource": { - "type": "influxdb", - "uid": "cdzg44tv250jkd" - }, - "fieldConfig": { - "defaults": { - "unit": "lengthm", - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "blue", - "value": null - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 12, - "x": 0, - "y": 24 - }, - "id": 14, - "options": { - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "colorMode": "value", - "graphMode": "none" - }, - "targets": [ - { - "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"inflowLevel\" or r._field==\"outflowLevel\" or r._field==\"overflowLevel\" or r._field==\"heightBasin\"))\n |> group(columns:[\"_field\"])\n |> last()", - "refId": "A" - } - ], - "title": "Heights", - "type": "stat", - "meta": { - "emittedFields": [ - "heights.min", - "heights.max" - ] - } - }, - { - "datasource": { - "type": "influxdb", - "uid": "cdzg44tv250jkd" - }, - "fieldConfig": { - "defaults": { - "unit": "m3", - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "blue", - "value": null - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 12, - "x": 12, - "y": 24 - }, - "id": 15, - "options": { - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "colorMode": "value", - "graphMode": "none" - }, - "targets": [ - { - "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"maxVol\" or r._field==\"minVol\" or r._field==\"maxVolAtOverflow\" or r._field==\"minVolAtOutflow\" or r._field==\"minVolAtInflow\"))\n |> group(columns:[\"_field\"])\n |> last()", - "refId": "A" - } - ], - "title": "Volume Limits", - "type": "stat", - "meta": { - "emittedFields": [ - "volume.min", - "volume.max" - ] - } + "meta": { "emittedFields": ["flow.in", "flow.out"] } } ], "schemaVersion": 39, - "tags": [ - "EVOLV", - "pumpingStation", - "template" - ], + "tags": ["EVOLV", "pumpingStation", "template"], "templating": { "list": [ { @@ -642,60 +579,27 @@ "type": "custom", "label": "dbase", "query": "cdzg44tv250jkd", - "current": { - "text": "cdzg44tv250jkd", - "value": "cdzg44tv250jkd", - "selected": false - }, - "options": [ - { - "text": "cdzg44tv250jkd", - "value": "cdzg44tv250jkd", - "selected": true - } - ], + "current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false }, + "options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }], "hide": 2 }, { "name": "measurement", "type": "custom", "query": "template", - "current": { - "text": "template", - "value": "template", - "selected": false - }, - "options": [ - { - "text": "template", - "value": "template", - "selected": true - } - ] + "current": { "text": "template", "value": "template", "selected": false }, + "options": [{ "text": "template", "value": "template", "selected": true }] }, { "name": "bucket", "type": "custom", "query": "lvl2", - "current": { - "text": "lvl2", - "value": "lvl2", - "selected": false - }, - "options": [ - { - "text": "lvl2", - "value": "lvl2", - "selected": true - } - ] + "current": { "text": "lvl2", "value": "lvl2", "selected": false }, + "options": [{ "text": "lvl2", "value": "lvl2", "selected": true }] } ] }, - "time": { - "from": "now-6h", - "to": "now" - }, + "time": { "from": "now-6h", "to": "now" }, "timezone": "", "title": "template", "uid": null, diff --git a/package.json b/package.json index ee45ab3..6e32fa7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dashboardAPI", - "version": "1.0.0", + "version": "1.1.0", "description": "EVOLV Grafana dashboard generator (Node-RED node).", "main": "dashboardAPI.js", "scripts": { diff --git a/src/specificClass.js b/src/specificClass.js index 33c035d..0729a1e 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -47,6 +47,20 @@ function defaultBucketForPosition(positionVsParent) { return 'lvl2'; } +// Replace `{{name}}` placeholders in a raw JSON template string with values +// from `vars`. Unknown placeholders are left intact. Used to inject node-config +// derived constants (basin geometry, threshold y-positions) into a template +// before JSON.parse — so the resulting dashboard has concrete numbers in +// fieldConfig.thresholds and canvas element placements. Mustache-style braces +// keep these placeholders distinct from Grafana's own `${var}` dashboard +// variables (which are interpreted by Grafana at render time). +function substituteTemplateVars(rawJson, vars) { + if (!vars || !Object.keys(vars).length) return rawJson; + return rawJson.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (m, key) => ( + Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : m + )); +} + function updateTemplatingVar(dashboard, varName, value) { const list = dashboard?.templating?.list; if (!Array.isArray(list)) return; @@ -140,13 +154,94 @@ class DashboardApi { return null; } - loadTemplate(softwareType) { + loadTemplate(softwareType, templateVars = null) { const templatePath = this._templateFileForSoftwareType(softwareType); if (!templatePath) return null; - const raw = fs.readFileSync(templatePath, 'utf8'); + let raw = fs.readFileSync(templatePath, 'utf8'); + // Always substitute — falls back to per-softwareType defaults so the + // template is JSON-parseable even when no nodeConfig is provided (tests, + // smoke-loading, etc.). _templateVarsForNode returns {} for types that + // don't use placeholders, which is a no-op pass. + const vars = templateVars || this._templateVarsForNode(softwareType, null); + raw = substituteTemplateVars(raw, vars); return JSON.parse(raw); } + // Per-softwareType numeric vars baked into the template before JSON.parse. + // Today only pumpingStation needs this (basin geometry → bar-gauge thresholds + // and canvas y-positions). Other types return {} and skip substitution. + _templateVarsForNode(softwareType, nodeConfig) { + const st = String(softwareType || '').toLowerCase(); + if (st !== 'pumpingstation') return {}; + + // configManager.buildConfig nests basin geometry under `basin.*` and + // safety percentages under `safety.*` (see generalFunctions/configManager). + const basin = nodeConfig?.basin || {}; + const safety = nodeConfig?.safety || {}; + const heightBasin = Number(basin.height) || 4; + const inflowLevel = Number(basin.inflowLevel) || 0; + const outflowLevel = Number(basin.outflowLevel) || 0; + const overflowLevel = Number(basin.overflowLevel) || heightBasin; + const dryRunPct = Number(safety.dryRunThresholdPercent) || 30; + const highPct = Number(safety.highVolumeSafetyThresholdPercent) || 90; + + // Mirror specificClass._computeSafetyPoints derivation (pumpingStation). + const dryRunLevel = outflowLevel * (1 + dryRunPct / 100); + const highSafetyLevel = overflowLevel * (highPct / 100); + + // Canvas tank: rim at y=20px, floor at y=260px (240px tall). Must match + // hard-coded tank rectangle placement in config/pumpingStation.json. + const TANK_TOP = 20, TANK_BOT = 260, TANK_H = TANK_BOT - TANK_TOP; + const yFor = (v) => +(TANK_BOT - (v / heightBasin) * TANK_H).toFixed(2); + const tyFor = (yLine) => +(yLine - 8).toFixed(2); // centre 16px text on the line + + const y_overflow = yFor(overflowLevel); + const y_highSafety = yFor(highSafetyLevel); + const y_inflow = yFor(inflowLevel); + const y_dryRun = yFor(dryRunLevel); + const y_outflow = yFor(outflowLevel); + + // Label y-positions get min-gap enforcement so labels never overlap even + // when thresholds sit nearly on top of each other (e.g. dryRun=2 % means + // dryRunLevel sits right on outflowLevel; highSafety=98 % puts it under + // overflow). Lines stay at proportional y; only the label text moves. + // Two-pass (down + up) mirrors editor's basin-diagram.js placement logic. + const GAP = 20; + const labels = [ + { id: 'overflow', y: tyFor(y_overflow) }, + { id: 'highSafety', y: tyFor(y_highSafety) }, + { id: 'inflow', y: tyFor(y_inflow) }, + { id: 'dryRun', y: tyFor(y_dryRun) }, + { id: 'outflow', y: tyFor(y_outflow) }, + ].sort((a, b) => a.y - b.y); + for (let i = 1; i < labels.length; i++) { + if (labels[i].y < labels[i - 1].y + GAP) labels[i].y = labels[i - 1].y + GAP; + } + for (let i = labels.length - 2; i >= 0; i--) { + if (labels[i].y > labels[i + 1].y - GAP) labels[i].y = labels[i + 1].y - GAP; + } + const ty = Object.fromEntries(labels.map((l) => [l.id, +l.y.toFixed(2)])); + + return { + heightBasin: +heightBasin.toFixed(2), + outflowLevel: +outflowLevel.toFixed(3), + inflowLevel: +inflowLevel.toFixed(3), + overflowLevel: +overflowLevel.toFixed(3), + dryRunLevel: +dryRunLevel.toFixed(3), + highSafetyLevel: +highSafetyLevel.toFixed(3), + y_overflow, y_highSafety, y_inflow, y_dryRun, y_outflow, + h_spill: +(y_overflow - TANK_TOP).toFixed(2), + h_highSafety: +(y_highSafety - y_overflow).toFixed(2), + h_operating: +(y_outflow - y_highSafety).toFixed(2), + h_dead: +(TANK_BOT - y_outflow).toFixed(2), + ty_overflow: ty.overflow, + ty_highSafety: ty.highSafety, + ty_inflow: ty.inflow, + ty_dryRun: ty.dryRun, + ty_outflow: ty.outflow, + }; + } + // 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. @@ -248,7 +343,8 @@ class DashboardApi { const title = nodeConfig?.general?.name || String(nodeId); // Missing templates are treated as non-fatal: we skip only that dashboard. - const dashboard = this.loadTemplate(softwareType); + const templateVars = this._templateVarsForNode(softwareType, nodeConfig); + const dashboard = this.loadTemplate(softwareType, templateVars); if (!dashboard) { this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`); return null;