5 Commits

Author SHA1 Message Date
znetsixe
8a26e17780 chore(dashboardAPI): Tank Layout fills card vertically too
Canvas frame height 600 → 760 px and tank rectangle height 520 → 680 px
so the visual fills the card aspect (taller than wide). Floor footer
moves to y=702 (was 542) to stay just below the new tank floor.

In-canvas bottom readouts (level / volume / fill mini-stats) removed —
they were redundant with the Status row Level stat, the bar gauge, and
the Level/Volume timeseries, and were getting clipped below the card's
visible area anyway. The basin canvas now shows only basin-structure
information (geometry, zones, thresholds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:12:01 +02:00
znetsixe
3cd749bf37 chore(dashboardAPI): inline basin labels — tank fills card width
Tank visual now fills the Canvas card edge-to-edge instead of leaving
horizontal padding for external name + value label columns. Each
threshold's name and value sit INSIDE the tank near its line ('overflow-
Level  3.22 m', 'highSafety  3.16 m', etc.), right-aligned at the tank's
inner right edge.

Tank rectangle, zone tints, threshold lines, header rim, and footer floor
all widen from left:80 width:200 → left:10 width:380 to fill the frame.
Label colors darkened slightly (e.g. #e54343 → #c92020) to keep contrast
against the semi-transparent zone tint backgrounds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:59:31 +02:00
znetsixe
70151e52ec chore(dashboardAPI): Tank Layout card width matches its visual
Canvas frame logical width: 480 → 400 px (was leaving ~104 px of empty
space on the right inside the card). Panel grid width: 8 → 6 cols so the
card pixel width matches the frame logical width and content fills it
without horizontal padding, instead of letterboxing in the centre.

Bottom readouts repositioned to fit within 400 px (level/volume/fill all
inside the new frame width) and per-field decimal overrides added so unit
formatting doesn't truncate ('100.00 mm' fits in the value label width).

Freed grid cols flow to the Level + Volume timeseries on the right
(w:12 → 14 each, x:12 → 10) so the right half consumes the rest of the
row without a gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:53:08 +02:00
znetsixe
b3972d4a2f chore(dashboardAPI): double basin row height for pumpingStation
Basin row grows from h:10 to h:20. Bar gauge, Canvas, and Level/Volume
timeseries all scale proportionally. Canvas internal frame doubled (480x600)
and tank rectangle stretched (height 240→520) so the canvas content fills
the panel instead of letterboxing in the top half. Bottom readouts moved
from y=280 to y=562 to stay just below the taller tank floor.

Flow row + its panels shifted down by 10 grid rows to make room.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:45:51 +02:00
znetsixe
3529c9f970 feat(dashboardAPI): basin canvas + bar gauge for pumpingStation
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) <noreply@anthropic.com>
2026-05-28 10:32:52 +02:00
3 changed files with 427 additions and 472 deletions

View File

@@ -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,322 @@
],
"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": 20, "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" }, { "id": "decimals", "value": 2 }]
},
{
"matcher": { "id": "byRegexp", "options": "^(volume|maxVol|minVol|maxVolAtOverflow|minVolAtOutflow|minVolAtInflow)$" },
"properties": [{ "id": "unit", "value": "m3" }, { "id": "decimals", "value": 2 }]
},
{
"matcher": { "id": "byRegexp", "options": "^volumePercent$" },
"properties": [{ "id": "unit", "value": "percent" }, { "id": "decimals", "value": 1 }]
}
]
},
"gridPos": { "h": 20, "w": 6, "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": 400, "height": 760 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "dark-green" } },
"elements": [
{
"name": "Zone Spill",
"type": "rectangle",
"placement": { "top": 20, "left": 10, "width": 380, "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": 10, "width": 380, "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": 10, "width": 380, "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": 10, "width": 380, "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": 10, "width": 380, "height": 680 },
"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": 10, "width": 380, "height": 1 },
"background": { "color": { "fixed": "#e54343" } },
"border": { "color": { "fixed": "#e54343" }, "width": 0 }
},
{
"name": "Line HighSafety",
"type": "rectangle",
"placement": { "top": {{y_highSafety}}, "left": 10, "width": 380, "height": 1 },
"background": { "color": { "fixed": "#f2a543" } },
"border": { "color": { "fixed": "#f2a543" }, "width": 0 }
},
{
"name": "Line Inflow",
"type": "rectangle",
"placement": { "top": {{y_inflow}}, "left": 10, "width": 380, "height": 1 },
"background": { "color": { "fixed": "#5fb37a" } },
"border": { "color": { "fixed": "#5fb37a" }, "width": 0 }
},
{
"name": "Line DryRun",
"type": "rectangle",
"placement": { "top": {{y_dryRun}}, "left": 10, "width": 380, "height": 1 },
"background": { "color": { "fixed": "#5b9bd5" } },
"border": { "color": { "fixed": "#5b9bd5" }, "width": 0 }
},
{
"name": "Line Outflow",
"type": "rectangle",
"placement": { "top": {{y_outflow}}, "left": 10, "width": 380, "height": 1 },
"background": { "color": { "fixed": "#bfbfbf" } },
"border": { "color": { "fixed": "#bfbfbf" }, "width": 0 }
},
{
"name": "Label Overflow Name",
"type": "text",
"placement": { "top": {{ty_overflow}}, "left": 180, "width": 140, "height": 16 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 11, "align": "right", "valign": "middle" }
},
{
"name": "Label HighSafety Name",
"type": "text",
"placement": { "top": {{ty_highSafety}}, "left": 180, "width": 140, "height": 16 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "highSafety" }, "color": { "fixed": "#cf7e20" }, "size": 11, "align": "right", "valign": "middle" }
},
{
"name": "Label Inflow Name",
"type": "text",
"placement": { "top": {{ty_inflow}}, "left": 180, "width": 140, "height": 16 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 11, "align": "right", "valign": "middle" }
},
{
"name": "Label DryRun Name",
"type": "text",
"placement": { "top": {{ty_dryRun}}, "left": 180, "width": 140, "height": 16 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 11, "align": "right", "valign": "middle" }
},
{
"name": "Label Outflow Name",
"type": "text",
"placement": { "top": {{ty_outflow}}, "left": 180, "width": 140, "height": 16 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 11, "align": "right", "valign": "middle" }
},
{
"name": "Value Overflow",
"type": "metric-value",
"placement": { "top": {{ty_overflow}}, "left": 323, "width": 65, "height": 16 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 11, "align": "left", "valign": "middle" }
},
{
"name": "Value HighSafety",
"type": "metric-value",
"placement": { "top": {{ty_highSafety}}, "left": 323, "width": 65, "height": 16 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "highVolumeSafetyLevel" }, "color": { "fixed": "#cf7e20" }, "size": 11, "align": "left", "valign": "middle" }
},
{
"name": "Value Inflow",
"type": "metric-value",
"placement": { "top": {{ty_inflow}}, "left": 323, "width": 65, "height": 16 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 11, "align": "left", "valign": "middle" }
},
{
"name": "Value DryRun",
"type": "metric-value",
"placement": { "top": {{ty_dryRun}}, "left": 323, "width": 65, "height": 16 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 11, "align": "left", "valign": "middle" }
},
{
"name": "Value Outflow",
"type": "metric-value",
"placement": { "top": {{ty_outflow}}, "left": 323, "width": 65, "height": 16 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 11, "align": "left", "valign": "middle" }
},
{
"name": "Header Rim",
"type": "text",
"placement": { "top": 2, "left": 10, "width": 380, "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": 702, "left": 10, "width": 380, "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" }
}
]
}
},
"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": 10, "w": 14, "x": 10, "y": 6 },
"id": 8,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
"legend": { "displayMode": "list", "placement": "bottom" },
"tooltip": { "mode": "multi" }
},
"targets": [
{
@@ -356,45 +436,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": 10, "w": 14, "x": 10, "y": 16 },
"id": 9,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
"legend": { "displayMode": "list", "placement": "bottom" },
"tooltip": { "mode": "multi" }
},
"targets": [
{
@@ -402,56 +461,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": 26 },
"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": 27 },
"id": 11,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
"legend": { "displayMode": "list", "placement": "bottom" },
"tooltip": { "mode": "multi" }
},
"targets": [
{
@@ -461,44 +494,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": 27 },
"id": 12,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
"legend": { "displayMode": "list", "placement": "bottom" },
"tooltip": { "mode": "multi" }
},
"targets": [
{
@@ -508,133 +519,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 +531,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,

View File

@@ -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": {

View File

@@ -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,97 @@ 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=700px (680px tall). Must match
// hard-coded tank rectangle placement in config/pumpingStation.json
// (basin row is h:20 grid rows; canvas root frame is 400x760 px — taller
// than wide to match the card's aspect ratio so the tank fills the card
// vertically with no letterboxing).
const TANK_TOP = 20, TANK_BOT = 700, 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 +346,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;