Compare commits

..

8 Commits

Author SHA1 Message Date
znetsixe
869ba4fca5 docs: add CLAUDE.md with S88 classification and superproject rule reference
References the flow-layout rule set in the EVOLV superproject
(.claude/rules/node-red-flow-layout.md) so Claude Code sessions working
in this repo know the S88 level, colour, and placement lane for this node.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:48:20 +02:00
Rene De Ren
66b91883ac Fix dashboardapi adapter and Jest coverage 2026-03-12 16:46:50 +01:00
Rene De Ren
c5272fcc24 Adopt buildConfig in dashboardapi adapter 2026-03-12 16:43:29 +01:00
znetsixe
89d2260351 updates 2026-03-11 11:13:44 +01:00
znetsixe
547333be7d working 2026-02-23 13:16:58 +01:00
znetsixe
b285d8e83a before functional changes by codex 2026-02-19 17:37:36 +01:00
znetsixe
1ea4788848 update dashboardAPI -AGENT 2026-01-13 14:29:43 +01:00
znetsixe
c99a93f73b removed error inducing module deprecated 2025-11-13 19:38:09 +01:00
30 changed files with 1727 additions and 8391 deletions

23
CLAUDE.md Normal file
View File

@@ -0,0 +1,23 @@
# dashboardAPI — Claude Code context
InfluxDB telemetry and FlowFuse chart endpoints.
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
## S88 classification
| Level | Colour | Placement lane |
|---|---|---|
| **Utility (no S88 level)** | `none` | n/a |
## Flow layout rules
When wiring this node into a multi-node demo or production flow, follow the
placement rule set in the **EVOLV superproject**:
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
Key points for this node:
- Place on lane **n/a** (x-position per the lane table in the rule).
- Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- Wrap in a Node-RED group box coloured `none` (Utility (no S88 level)).

71
config/dashboardapi.json Normal file
View File

@@ -0,0 +1,71 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "DashboardAPI", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 9, "w": 24, "x": 0, "y": 1 },
"id": 2,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\")\n |> aggregateWindow(every: v.windowPeriod, fn: count, createEmpty: false)",
"refId": "A"
}
],
"title": "Upsert Activity (if logged)",
"type": "timeseries"
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "dashboardapi", "template"],
"templating": {
"list": [
{
"name": "dbase",
"type": "custom",
"label": "dbase",
"query": "cdzg44tv250jkd",
"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 }]
},
{
"name": "bucket",
"type": "custom",
"query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
}
]
},
"time": { "from": "now-6h", "to": "now" },
"timezone": "",
"title": "template",
"uid": null,
"version": 1
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

171
config/pumpingStation.json Normal file
View File

@@ -0,0 +1,171 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Status", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 5, "x": 0, "y": 1 },
"id": 2,
"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==\"direction\")\n |> last()", "refId": "A" }
],
"title": "Direction",
"type": "stat"
},
{
"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 }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 5, "x": 5, "y": 1 },
"id": 3,
"options": { "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==\"timeleft\")\n |> last()", "refId": "A" }
],
"title": "Time Left",
"type": "stat"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "purple", "value": null }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 4, "x": 10, "y": 1 },
"id": 4,
"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==\"flowSource\")\n |> last()", "refId": "A" }
],
"title": "Flow Source",
"type": "stat"
},
{
"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 |> last()", "refId": "A" }
],
"title": "Fill %",
"type": "gauge"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 5, "x": 19, "y": 1 },
"id": 6,
"options": { "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 |> last()", "refId": "A" }
],
"title": "Level",
"type": "stat"
},
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, "id": 7, "title": "Basin", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 },
"id": 8,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.(predicted|measured)\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
],
"title": "Level",
"type": "timeseries"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m\u00b3", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 },
"id": 9,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^volume\\.predicted\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
],
"title": "Volume",
"type": "timeseries"
},
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, "id": 10, "title": "Flow", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m\u00b3/h", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 },
"id": 11,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^netFlowRate\\.predicted\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
],
"title": "Net Flow Rate",
"type": "timeseries"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m\u00b3/h", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 },
"id": 12,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^flow\\.(predicted|measured)\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
],
"title": "Inflow + Outflow",
"type": "timeseries"
},
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, "id": 13, "title": "Configuration", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m", "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==\"heightInlet\" or r._field==\"heightOverflow\" or r._field==\"volEmptyBasin\"))\n |> last()", "refId": "A" }
],
"title": "Heights",
"type": "stat"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m\u00b3", "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==\"maxVolOverflow\" or r._field==\"minVolOut\" or r._field==\"minVolIn\"))\n |> last()", "refId": "A" }
],
"title": "Volume Limits",
"type": "stat"
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "pumpingStation", "template"],
"templating": {
"list": [
{ "name": "dbase", "type": "custom", "label": "dbase", "query": "cdzg44tv250jkd", "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 }] },
{ "name": "bucket", "type": "custom", "query": "lvl2", "current": { "text": "lvl2", "value": "lvl2", "selected": false }, "options": [{ "text": "lvl2", "value": "lvl2", "selected": true }] }
]
},
"time": { "from": "now-6h", "to": "now" },
"timezone": "",
"title": "template",
"uid": null,
"version": 1
}

97
config/reactor.json Normal file
View File

@@ -0,0 +1,97 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Status", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "mg/L", "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 0.5 }, { "color": "green", "value": 1.5 }, { "color": "yellow", "value": 4 }, { "color": "red", "value": 6 }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 },
"id": 2,
"options": { "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 =~ /^S_O/)\n |> last()", "refId": "A" }
],
"title": "DO (S_O)",
"type": "stat"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "mg/L", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 2 }, { "color": "red", "value": 5 }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 },
"id": 3,
"options": { "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 =~ /^S_NH/)\n |> last()", "refId": "A" }
],
"title": "NH\u2084 (S_NH)",
"type": "stat"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "mg/L", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 5 }, { "color": "red", "value": 10 }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 },
"id": 4,
"options": { "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 =~ /^S_NO/)\n |> last()", "refId": "A" }
],
"title": "NO\u2083 (S_NO)",
"type": "stat"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "mg/L", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 3000 }, { "color": "red", "value": 5000 }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 },
"id": 5,
"options": { "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 =~ /^X_TS/)\n |> last()", "refId": "A" }
],
"title": "TSS (X_TS)",
"type": "stat"
},
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, "id": 6, "title": "Trends", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 5 } }, "overrides": [] },
"gridPos": { "h": 9, "w": 24, "x": 0, "y": 6 },
"id": 7,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\")\n |> filter(fn:(r) => r._field =~ /^(F|S_O|S_NH|S_NO|S_S|X_TS)/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
],
"title": "Core Process Signals",
"type": "timeseries"
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "reactor", "template"],
"templating": {
"list": [
{ "name": "dbase", "type": "custom", "label": "dbase", "query": "cdzg44tv250jkd", "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 }] },
{ "name": "bucket", "type": "custom", "query": "lvl2", "current": { "text": "lvl2", "value": "lvl2", "selected": false }, "options": [{ "text": "lvl2", "value": "lvl2", "selected": true }] }
]
},
"time": { "from": "now-6h", "to": "now" },
"timezone": "",
"title": "template",
"uid": null,
"version": 1
}

71
config/settler.json Normal file
View File

@@ -0,0 +1,71 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Settler (Simulation/Process)", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 9, "w": 24, "x": 0, "y": 1 },
"id": 2,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\")\n |> filter(fn:(r) => r._field =~ /^(F_in|F_eff|F_so|F_sr|C_TS)/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Flows / Solids (if logged)",
"type": "timeseries"
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "settler", "template"],
"templating": {
"list": [
{
"name": "dbase",
"type": "custom",
"label": "dbase",
"query": "cdzg44tv250jkd",
"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 }]
},
{
"name": "bucket",
"type": "custom",
"query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
}
]
},
"time": { "from": "now-6h", "to": "now" },
"timezone": "",
"title": "template",
"uid": null,
"version": 1
}

85
config/valve.json Normal file
View File

@@ -0,0 +1,85 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Realtime Valve", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 1 },
"id": 2,
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -30d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"state\" or r._field==\"mode\" or r._field==\"percentageOpen\"))\n |> last()",
"refId": "A"
}
],
"title": "State / Mode / %Open (last)",
"type": "stat"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] },
"gridPos": { "h": 9, "w": 16, "x": 8, "y": 1 },
"id": 3,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"downstream_predicted_flow\" or r._field==\"downstream_measured_flow\" or r._field==\"delta_predicted_pressure\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Flow + ΔP",
"type": "timeseries"
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "valve", "template"],
"templating": {
"list": [
{
"name": "dbase",
"type": "custom",
"label": "dbase",
"query": "cdzg44tv250jkd",
"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 }]
},
{
"name": "bucket",
"type": "custom",
"query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
}
]
},
"time": { "from": "now-6h", "to": "now" },
"timezone": "",
"title": "template",
"uid": null,
"version": 1
}

View File

@@ -0,0 +1,85 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Realtime Valve Group", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 1 },
"id": 2,
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -30d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"mode\" or r._field==\"maxDeltaP\"))\n |> last()",
"refId": "A"
}
],
"title": "Mode / maxΔP (last)",
"type": "stat"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] },
"gridPos": { "h": 9, "w": 16, "x": 8, "y": 1 },
"id": 3,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field =~ /predicted_flow|measured_flow/ or r._field==\"maxDeltaP\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Flow + maxΔP",
"type": "timeseries"
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "valveGroupControl", "template"],
"templating": {
"list": [
{
"name": "dbase",
"type": "custom",
"label": "dbase",
"query": "cdzg44tv250jkd",
"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 }]
},
{
"name": "bucket",
"type": "custom",
"query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
}
]
},
"time": { "from": "now-6h", "to": "now" },
"timezone": "",
"title": "template",
"uid": null,
"version": 1
}

View File

@@ -1,134 +1,94 @@
<script type="module">
<script src="/dashboardapi/menu.js"></script>
<script src="/dashboardapi/configData.js"></script>
import * as menuUtils from "/generalfunctions/helper/menuUtils.js";
<script>
RED.nodes.registerType('dashboardapi', {
category: 'EVOLV',
color: '#4f8582',
defaults: {
name: { value: '' },
enableLog: { value: false },
logLevel: { value: 'info' },
RED.nodes.registerType('dashboardapi', {
category: 'wbd typical',
color: '#4f8582',
defaults: {
name: { value: "" },
// New defaults for configuration:
logLevel: { value: "info" },
enableLog: { value: false },
host: { value: "" },
port: { value: 0 },
bearerToken: { value: "" }
},
inputs: 1,
outputs: 1,
inputLabels: "Usage see manual",
outputLabels: ["feedback"],
icon: "font-awesome/fa-area-chart",
protocol: { value: 'http' },
host: { value: 'localhost' },
port: { value: 3000 },
bearerToken: { value: '' },
defaultBucket: { value: '' },
},
inputs: 1,
outputs: 1,
inputLabels: ['Input'],
outputLabels: ['grafana'],
icon: 'font-awesome/fa-area-chart',
label: function () {
// Show the name
return this.name || "dashboardapi";
},
label: function () {
return this.name || 'dashboardapi';
},
oneditprepare: function () {
const node = this;
console.log("Edit Prepare");
const elements = {
// Basic fields
name: document.getElementById("node-input-name"),
number: document.getElementById("node-input-number"),
// Logging fields
logLevelSelect: document.getElementById("node-input-logLevel"),
logCheckbox: document.getElementById("node-input-enableLog"),
// Grafana connector fields
host: document.getElementById("node-input-host"),
port: document.getElementById("node-input-port"),
bearerToken: document.getElementById("node-input-bearerToken"),
};
try {
// UI elements
menuUtils.initBasicToggles(elements);
} catch (e) {
console.log("Error fetching project settings", e);
}
},
oneditsave: function () {
const node = this;
console.log(`------------ Saving changes to node ------------`);
//save basic properties
["name", "host", "port", "bearerToken"].forEach(
(field) => {
const element = document.getElementById(`node-input-${field}`);
if (element) {
node[field] = element.value || "";
}
}
);
const logLevelElement = document.getElementById("node-input-logLevel");
node.logLevel = logLevelElement ? logLevelElement.value || "info" : "info";
oneditprepare: function () {
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.dashboardapi?.loggerMenu?.initEditor) {
window.EVOLV.nodes.dashboardapi.loggerMenu.initEditor(this);
} else {
setTimeout(waitForMenuData, 50);
}
};
waitForMenuData();
},
});
oneditsave: function () {
const node = this;
if (window.EVOLV?.nodes?.dashboardapi?.loggerMenu?.saveEditor) {
window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node);
}
['name', 'protocol', 'host', 'port', 'bearerToken', 'defaultBucket'].forEach((field) => {
const element = document.getElementById(`node-input-${field}`);
if (!element) return;
node[field] = field === 'port' ? parseInt(element.value, 10) || 3000 : element.value || '';
});
},
});
</script>
<!-- Main UI Template -->
<script type="text/html" data-template-name="dashboardapi">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input
type="text"
id="node-input-name"
placeholder="name"
style="width:70%;"
/>
</div>
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="name" style="width:70%;" />
</div>
<div class="form-row">
<label for="node-input-protocol"><i class="fa fa-exchange"></i> Protocol</label>
<select id="node-input-protocol" style="width:70%;">
<option value="http">http</option>
<option value="https">https</option>
</select>
</div>
<div class="form-row">
<label for="node-input-host"><i class="fa fa-server"></i> Grafana Host</label>
<input type="text" id="node-input-host" placeholder="Host">
<input type="text" id="node-input-host" placeholder="localhost" style="width:70%;" />
</div>
<div class="form-row">
<label for="node-input-port"><i class="fa fa-plug"></i> Grafana Port</label>
<input type="number" id="node-input-port" placeholder="Port">
<input type="number" id="node-input-port" placeholder="3000" style="width:70%;" />
</div>
<div class="form-row">
<label for="node-input-bearerToken"><i class="fa fa-key"></i> Bearer Token</label>
<input type="text" id="node-input-bearerToken" placeholder="Bearer Token">
<input type="password" id="node-input-bearerToken" placeholder="optional" style="width:70%;" />
</div>
<hr />
<!-- loglevel checkbox -->
<div class="form-row">
<label for="node-input-enableLog"
><i class="fa fa-cog"></i> Enable Log</label
>
<input
type="checkbox"
id="node-input-enableLog"
style="width:20px; vertical-align:baseline;"
/>
<span>Enable logging</span>
</div>
<div class="form-row" id="row-logLevel">
<label for="node-input-logLevel"><i class="fa fa-cog"></i> Log Level</label>
<select id="node-input-logLevel" style="width:60%;">
<option value="info">Info</option>
<option value="debug">Debug</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
<div class="form-row">
<label for="node-input-defaultBucket"><i class="fa fa-database"></i> InfluxDB Bucket</label>
<input type="text" id="node-input-defaultBucket" placeholder="env INFLUXDB_BUCKET or lvl2" style="width:70%;" />
</div>
<div id="logger-fields-placeholder"></div>
</script>
<script type="text/html" data-help-name="dashboardapi">

View File

@@ -1,108 +1,41 @@
const fs = require('node:fs');
const path = require('node:path');
const nameOfNode = 'dashboardapi';
const nodeClass = require('./src/nodeClass.js');
const { MenuManager } = require('generalFunctions');
module.exports = function (RED) {
function dashboardapi(config) {
// create node
RED.nodes.registerType(nameOfNode, function (config) {
RED.nodes.createNode(this, config);
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
});
//call this => node so whenver you want to call a node function type node and the function behind it
var node = this;
const menuMgr = new MenuManager();
RED.httpAdmin.get(`/${nameOfNode}/menu.js`, (req, res) => {
try {
//fetch obj
const Dashboardapi = require("./dependencies/dashboardapi/dashboardapi_class");
//load user defined config in the node-red UI
const dConfig = {
general: {
name: config.name,
id: node.id,
logging: {
logLevel: config.logLevel,
enabled: config.enableLog,
},
},
grafanaConnector: {
host: config.host,
port: config.port,
bearerToken: config.bearerToken,
},
};
//make new measurement on creation to work with.
const d = new Dashboardapi(dConfig);
// put m on node memory as source
node.source = d;
function updateNodeStatus(val) {
if (val && val.grafanaResponse) {
// Check for a successful response from the Grafana API call
if (val.grafanaResponse.status === 200) {
node.status({
fill: "green",
shape: "dot",
text: "Grafana API: Success",
});
node.log("Grafana API call completed successfully.");
} else {
node.status({
fill: "red",
shape: "ring",
text: "Grafana API: Error",
});
node.error(
"Grafana API call failed with status: " +
val.grafanaResponse.status
);
}
}
}
//-------------------------------------------------------------------->>what to do on input
node.on("input", async function (msg, send, done) {
try {
switch(msg.topic) {
//on start make dashboard
case 'registerChild':
const childId = msg.payload;
const childObj = RED.nodes.getNode(childId);
if (!childObj || !childObj.source) {
throw new Error("Missing or invalid child node");
}
const child = childObj.source;
msg.payload = await d.generateDashB(child.config);
msg.topic = "create";
msg.headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer glsa_gI7fOMEd844p1gZt9iaDeEFpeYtejRj7_cf1c41f8'// + config.bearerToken
};
console.log(`Child registered: ${childId}`);
send(msg);
break;
}
done();
} catch (err) {
node.status({ fill: "red", shape: "ring", text: "Bad request data" });
node.error("Bad request data: " + err.message, msg);
done(err);
}
});
// tidy up any async code here - shutdown connections and so on.
node.on("close", function () {
});
} catch (e) {
console.log(e);
const script = menuMgr.createEndpoint(nameOfNode, ['logger']);
res.type('application/javascript').send(script);
} catch (err) {
res.status(500).send(`// Error generating menu: ${err.message}`);
}
}
});
RED.nodes.registerType("dashboardapi", dashboardapi);
// Provide config metadata for the editor (local, no dependency on generalFunctions configs).
RED.httpAdmin.get(`/${nameOfNode}/configData.js`, (req, res) => {
try {
const configPath = path.join(__dirname, 'dependencies', 'dashboardapi', 'dashboardapiConfig.json');
const json = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const script = `
window.EVOLV = window.EVOLV || {};
window.EVOLV.nodes = window.EVOLV.nodes || {};
window.EVOLV.nodes.${nameOfNode} = window.EVOLV.nodes.${nameOfNode} || {};
window.EVOLV.nodes.${nameOfNode}.config = ${JSON.stringify(json, null, 2)};
`;
res.type('application/javascript').send(script);
} catch (err) {
res.status(500).send(`// Error generating configData: ${err.message}`);
}
});
};

8
examples/README.md Normal file
View File

@@ -0,0 +1,8 @@
# dashboardAPI Example Flows
Import-ready Node-RED examples for dashboardAPI.
## Files
- basic.flow.json
- integration.flow.json
- edge.flow.json

6
examples/basic.flow.json Normal file
View File

@@ -0,0 +1,6 @@
[
{"id":"dashboardAPI_basic_tab","type":"tab","label":"dashboardAPI basic","disabled":false,"info":"dashboardAPI basic example"},
{"id":"dashboardAPI_basic_node","type":"dashboardapi","z":"dashboardAPI_basic_tab","name":"dashboardAPI basic","x":420,"y":180,"wires":[["dashboardAPI_basic_dbg"]]},
{"id":"dashboardAPI_basic_inj","type":"inject","z":"dashboardAPI_basic_tab","name":"basic trigger","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"ping","payload":"1","payloadType":"str","x":160,"y":180,"wires":[["dashboardAPI_basic_node"]]},
{"id":"dashboardAPI_basic_dbg","type":"debug","z":"dashboardAPI_basic_tab","name":"dashboardAPI basic debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
]

6
examples/edge.flow.json Normal file
View File

@@ -0,0 +1,6 @@
[
{"id":"dashboardAPI_edge_tab","type":"tab","label":"dashboardAPI edge","disabled":false,"info":"dashboardAPI edge example"},
{"id":"dashboardAPI_edge_node","type":"dashboardapi","z":"dashboardAPI_edge_tab","name":"dashboardAPI edge","x":420,"y":180,"wires":[["dashboardAPI_edge_dbg"]]},
{"id":"dashboardAPI_edge_inj","type":"inject","z":"dashboardAPI_edge_tab","name":"unknown topic","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"doesNotExist","payload":"x","payloadType":"str","x":170,"y":180,"wires":[["dashboardAPI_edge_node"]]},
{"id":"dashboardAPI_edge_dbg","type":"debug","z":"dashboardAPI_edge_tab","name":"dashboardAPI edge debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
]

View File

@@ -0,0 +1,6 @@
[
{"id":"dashboardAPI_int_tab","type":"tab","label":"dashboardAPI integration","disabled":false,"info":"dashboardAPI integration example"},
{"id":"dashboardAPI_int_node","type":"dashboardapi","z":"dashboardAPI_int_tab","name":"dashboardAPI integration","x":420,"y":180,"wires":[["dashboardAPI_int_dbg"]]},
{"id":"dashboardAPI_int_inj","type":"inject","z":"dashboardAPI_int_tab","name":"registerChild","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"registerChild","payload":"example-child-id","payloadType":"str","x":170,"y":180,"wires":[["dashboardAPI_int_node"]]},
{"id":"dashboardAPI_int_dbg","type":"debug","z":"dashboardAPI_int_tab","name":"dashboardAPI integration debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":680,"y":180,"wires":[]}
]

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "dashboardAPI",
"version": "1.0.0",
"description": "EVOLV Grafana dashboard generator (Node-RED node).",
"main": "dashboardapi.js",
"scripts": {
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
},
"keywords": [
"dashboard",
"grafana",
"node-red",
"EVOLV"
],
"author": "EVOLV",
"license": "SEE LICENSE",
"dependencies": {
"generalFunctions": "git+https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions.git"
},
"node-red": {
"nodes": {
"dashboardapi": "dashboardapi.js"
}
}
}

134
src/nodeClass.js Normal file
View File

@@ -0,0 +1,134 @@
const { configManager } = require('generalFunctions');
const DashboardApi = require('./specificClass');
class nodeClass {
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
this.node = nodeInstance;
this.RED = RED;
this.name = nameOfNode;
this.source = null;
this.config = null;
this._loadConfig(uiConfig);
this._setupSpecificClass();
this._attachInputHandler();
this._attachCloseHandler();
}
_loadConfig(uiConfig) {
const cfgMgr = new configManager();
this.config = cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
functionality: {
softwareType: this.name.toLowerCase(),
role: 'auto ui generator',
},
grafanaConnector: {
protocol: uiConfig.protocol || 'http',
host: uiConfig.host || 'localhost',
port: Number(uiConfig.port || 3000),
bearerToken: uiConfig.bearerToken || '',
},
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',
});
}
_setupSpecificClass() {
this.source = new DashboardApi(this.config);
this.node.source = this.source;
}
_resolveChildNode(childId) {
const runtimeNode = this.RED.nodes.getNode(childId);
if (runtimeNode?.source?.config) {
return runtimeNode;
}
const flowNode = this.node._flow?.getNode?.(childId);
if (flowNode?.source?.config) {
return flowNode;
}
return runtimeNode || flowNode || null;
}
_resolveChildSource(payload) {
if (payload?.source?.config) {
return payload.source;
}
if (payload?.config) {
return { config: payload.config };
}
if (typeof payload === 'string') {
return this._resolveChildNode(payload)?.source || null;
}
return null;
}
_attachInputHandler() {
this.node.on('input', async (msg, send, done) => {
try {
if (msg.topic !== 'registerChild') {
if (typeof done === 'function') done();
return;
}
const childSource = this._resolveChildSource(msg.payload);
if (!childSource?.config) {
throw new Error('Missing or invalid child node');
}
const dashboards = this.source.generateDashboardsForGraph(childSource, {
includeChildren: Boolean(msg.includeChildren ?? true),
});
const url = this.source.grafanaUpsertUrl();
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
if (this.config.grafanaConnector.bearerToken) {
headers.Authorization = `Bearer ${this.config.grafanaConnector.bearerToken}`;
}
for (const dash of dashboards) {
send({
...msg,
topic: 'create',
url,
method: 'POST',
headers,
payload: this.source.buildUpsertRequest({
dashboard: dash.dashboard,
folderId: 0,
overwrite: true,
}),
meta: {
nodeId: dash.nodeId,
softwareType: dash.softwareType,
uid: dash.uid,
title: dash.title,
},
});
}
if (typeof done === 'function') done();
} catch (error) {
this.node.status({ fill: 'red', shape: 'ring', text: 'dashboardapi error' });
this.node.error(error?.message || error, msg);
if (typeof done === 'function') done(error);
}
});
}
_attachCloseHandler() {
this.node.on('close', (done) => {
if (typeof done === 'function') done();
});
}
}
module.exports = nodeClass;

211
src/specificClass.js Normal file
View File

@@ -0,0 +1,211 @@
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { logger } = require('generalFunctions');
function stableUid(input) {
const digest = crypto.createHash('sha1').update(String(input)).digest('hex');
return digest.slice(0, 12);
}
function slugify(input) {
return String(input || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 60);
}
function defaultBucketForPosition(positionVsParent) {
const pos = String(positionVsParent || '').toLowerCase();
if (pos === 'upstream') return 'lvl1';
if (pos === 'downstream') return 'lvl3';
return 'lvl2';
}
function updateTemplatingVar(dashboard, varName, value) {
const list = dashboard?.templating?.list;
if (!Array.isArray(list)) return;
const variable = list.find((v) => v && v.name === varName);
if (!variable) return;
variable.current = variable.current || {};
variable.current.text = value;
variable.current.value = value;
if (Array.isArray(variable.options) && variable.options.length > 0) {
variable.options[0] = variable.options[0] || {};
variable.options[0].text = value;
variable.options[0].value = value;
}
variable.query = value;
}
/**
* Dashboard domain service.
* Builds Grafana dashboard payloads from EVOLV node config and child topology.
*/
class DashboardApi {
constructor(config = {}) {
this.config = {
general: {
name: config?.general?.name || 'dashboardapi',
logging: {
enabled: Boolean(config?.general?.logging?.enabled),
logLevel: config?.general?.logging?.logLevel || 'info',
},
},
grafanaConnector: {
protocol: config?.grafanaConnector?.protocol || 'http',
host: config?.grafanaConnector?.host || 'localhost',
port: Number(config?.grafanaConnector?.port || 3000),
bearerToken: config?.grafanaConnector?.bearerToken || '',
},
defaultBucket: config?.defaultBucket || '',
bucketMap: config?.bucketMap || {},
};
this.logger = new logger(
this.config.general.logging.enabled,
this.config.general.logging.logLevel,
this.config.general.name
);
}
_templatesDir() {
return path.join(__dirname, '..', 'config');
}
_templateFileForSoftwareType(softwareType) {
const st = String(softwareType || '').trim();
const candidates = [
`${st}.json`,
`${st.toLowerCase()}.json`,
st === 'machineGroupControl' ? 'machineGroup.json' : null,
].filter(Boolean);
for (const filename of candidates) {
const fullPath = path.join(this._templatesDir(), filename);
if (fs.existsSync(fullPath)) return fullPath;
}
this.logger.warn(`No dashboard template found for softwareType=${st}`);
return null;
}
loadTemplate(softwareType) {
const templatePath = this._templateFileForSoftwareType(softwareType);
if (!templatePath) return null;
const raw = fs.readFileSync(templatePath, 'utf8');
return JSON.parse(raw);
}
grafanaUpsertUrl() {
const { protocol, host, port } = this.config.grafanaConnector;
return `${protocol}://${host}:${port}/api/dashboards/db`;
}
buildDashboard({ nodeConfig, positionVsParent }) {
const softwareType =
nodeConfig?.functionality?.softwareType ||
nodeConfig?.functionality?.software_type ||
'measurement';
const nodeId = nodeConfig?.general?.id || nodeConfig?.general?.name || softwareType;
const measurementName = `${softwareType}_${nodeId}`;
const title = nodeConfig?.general?.name || String(nodeId);
// Missing templates are treated as non-fatal: we skip only that dashboard.
const dashboard = this.loadTemplate(softwareType);
if (!dashboard) {
this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`);
return null;
}
const uid = stableUid(`${softwareType}:${nodeId}`);
dashboard.id = null;
dashboard.uid = uid;
dashboard.title = title;
dashboard.tags = Array.from(
new Set([...(dashboard.tags || []), 'EVOLV', softwareType, String(positionVsParent || '')].filter(Boolean))
);
const bucket =
this.config.defaultBucket ||
this.config.bucketMap[String(positionVsParent)] ||
defaultBucketForPosition(positionVsParent);
updateTemplatingVar(dashboard, 'measurement', measurementName);
updateTemplatingVar(dashboard, 'bucket', bucket);
return { dashboard, uid, title, softwareType, nodeId, measurementName };
}
buildUpsertRequest({ dashboard, folderId = 0, overwrite = true }) {
return { dashboard, folderId, overwrite };
}
extractChildren(nodeSource) {
const out = [];
const reg = nodeSource?.childRegistrationUtils?.registeredChildren;
if (reg && typeof reg.values === 'function') {
for (const entry of reg.values()) {
const child = entry?.child;
if (!child?.config) continue;
out.push({ childSource: child, positionVsParent: entry?.position || child.positionVsParent });
}
return out;
}
return out;
}
generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) {
if (!rootSource?.config) {
this.logger.warn('generateDashboardsForGraph skipped: root source missing config');
return [];
}
const rootPosition = rootSource?.positionVsParent || rootSource?.config?.functionality?.positionVsParent;
const rootDash = this.buildDashboard({ nodeConfig: rootSource.config, positionVsParent: rootPosition });
if (!rootDash) return [];
const results = [rootDash];
if (!includeChildren) return results;
const children = this.extractChildren(rootSource);
for (const { childSource, positionVsParent } of children) {
const childDash = this.buildDashboard({ nodeConfig: childSource.config, positionVsParent });
if (childDash) results.push(childDash);
}
// Add links from the root dashboard to children dashboards (when possible)
if (children.length > 0) {
rootDash.dashboard.links = Array.isArray(rootDash.dashboard.links) ? rootDash.dashboard.links : [];
for (const { childSource } of children) {
const childConfig = childSource.config;
const childSoftwareType = childConfig?.functionality?.softwareType || 'measurement';
const childNodeId = childConfig?.general?.id || childConfig?.general?.name || childSoftwareType;
const childUid = stableUid(`${childSoftwareType}:${childNodeId}`);
const childTitle = childConfig?.general?.name || String(childNodeId);
rootDash.dashboard.links.push({
type: 'link',
title: childTitle,
url: `/d/${childUid}/${slugify(childTitle)}`,
tags: [],
targetBlank: false,
keepTime: true,
keepVariables: true,
});
}
}
return results;
}
}
module.exports = DashboardApi;

12
test/README.md Normal file
View File

@@ -0,0 +1,12 @@
# dashboardAPI Test Suite Layout
Required EVOLV layout:
- basic/
- integration/
- edge/
- helpers/
Baseline structure tests:
- basic/structure-module-load.basic.test.js
- integration/structure-examples.integration.test.js
- edge/structure-examples-node-type.edge.test.js

0
test/basic/.gitkeep Normal file
View File

View File

@@ -0,0 +1,7 @@
describe('dashboardAPI basic structure', () => {
it('module load smoke', () => {
expect(() => {
require('../../dashboardapi.js');
}).not.toThrow();
});
});

81
test/dashboardapi.test.js Normal file
View File

@@ -0,0 +1,81 @@
const DashboardApi = require('../src/specificClass');
function makeNodeSource({ id, name, softwareType, positionVsParent, children = [] }) {
const registeredChildren = new Map();
for (const child of children) {
registeredChildren.set(child.config.general.id, {
child,
softwareType: child.config.functionality.softwareType,
position: child.positionVsParent || child.config.functionality.positionVsParent,
registeredAt: Date.now(),
});
}
return {
config: {
general: { id, name },
functionality: { softwareType, positionVsParent },
},
positionVsParent,
childRegistrationUtils: { registeredChildren },
};
}
describe('DashboardApi specificClass', () => {
it('buildDashboard sets id=null, stable uid, title, measurement and bucket vars', () => {
const api = new DashboardApi({
general: { name: 'dashboardapi-test', logging: { enabled: false, logLevel: 'error' } },
grafanaConnector: { protocol: 'http', host: 'localhost', port: 3000, bearerToken: '' },
});
const nodeSource = makeNodeSource({
id: 'm-1',
name: 'PT-1',
softwareType: 'measurement',
positionVsParent: 'downstream',
});
const dash = api.buildDashboard({ nodeConfig: nodeSource.config, positionVsParent: 'downstream' });
expect(dash.dashboard.id).toBeNull();
expect(dash.uid).toHaveLength(12);
expect(dash.dashboard.uid).toBe(dash.uid);
expect(dash.dashboard.title).toBe('PT-1');
const templ = dash.dashboard.templating.list;
const measurement = templ.find((v) => v.name === 'measurement');
const bucket = templ.find((v) => v.name === 'bucket');
expect(measurement.current.value).toBe('measurement_m-1');
expect(bucket.current.value).toBe('lvl3');
});
it('generateDashboardsForGraph returns root + direct child dashboards and adds links', () => {
const api = new DashboardApi({
general: { name: 'dashboardapi-test', logging: { enabled: false, logLevel: 'error' } },
grafanaConnector: { protocol: 'http', host: 'localhost', port: 3000, bearerToken: '' },
});
const child = makeNodeSource({
id: 'c-1',
name: 'ChildSensor',
softwareType: 'measurement',
positionVsParent: 'upstream',
});
const root = makeNodeSource({
id: 'p-1',
name: 'ParentMachine',
softwareType: 'machine',
positionVsParent: 'atEquipment',
children: [child],
});
const results = api.generateDashboardsForGraph(root, { includeChildren: true });
expect(results).toHaveLength(2);
const rootDash = results[0];
expect(Array.isArray(rootDash.dashboard.links)).toBe(true);
expect(rootDash.dashboard.links.some((l) => l.url && l.url.includes('/d/'))).toBe(true);
});
});

0
test/edge/.gitkeep Normal file
View File

View File

@@ -0,0 +1,11 @@
const fs = require('node:fs');
const path = require('node:path');
const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/basic.flow.json'), 'utf8'));
describe('dashboardAPI edge example structure', () => {
it('basic example includes node type dashboardapi', () => {
const count = flow.filter((n) => n && n.type === 'dashboardapi').length;
expect(count).toBeGreaterThanOrEqual(1);
});
});

0
test/helpers/.gitkeep Normal file
View File

View File

View File

@@ -0,0 +1,23 @@
const fs = require('node:fs');
const path = require('node:path');
const dir = path.resolve(__dirname, '../../examples');
function loadJson(file) {
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
}
describe('dashboardAPI integration examples', () => {
it('examples package exists for dashboardAPI', () => {
for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
expect(fs.existsSync(path.join(dir, file))).toBe(true);
}
});
it('example flows are parseable arrays for dashboardAPI', () => {
for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
const parsed = loadJson(file);
expect(Array.isArray(parsed)).toBe(true);
}
});
});

179
test/nodeClass.test.js Normal file
View File

@@ -0,0 +1,179 @@
const NodeClass = require('../src/nodeClass');
jest.mock('../src/specificClass', () => {
return jest.fn().mockImplementation(() => ({
logger: {
warn: jest.fn(),
},
generateDashboardsForGraph: jest.fn(() => [{
dashboard: { title: 'ok' },
nodeId: 'child-node-id',
softwareType: 'measurement',
uid: 'child-uid',
title: 'ok',
}]),
buildUpsertRequest: jest.fn(({ dashboard, folderId, overwrite }) => ({
dashboard,
folderId,
overwrite,
})),
grafanaUpsertUrl: jest.fn(() => 'http://grafana:3000/api/dashboards/db'),
}));
});
const SpecificClass = require('../src/specificClass');
function createNodeHarness(flowNode = null) {
const handlers = {};
const node = {
id: 'dashboard-node-id',
on: jest.fn((event, handler) => {
handlers[event] = handler;
}),
status: jest.fn(),
error: jest.fn(),
_flow: {
getNode: jest.fn(() => flowNode),
},
};
return { node, handlers };
}
describe('dashboardAPI nodeClass', () => {
beforeEach(() => {
jest.clearAllMocks();
delete process.env.GRAFANA_TOKEN;
});
it('uses RED.nodes.getNode when it returns a runtime child', async () => {
const childNode = {
source: {
config: {
general: { name: 'child' },
functionality: { softwareType: 'measurement' },
},
},
};
const { node, handlers } = createNodeHarness();
const RED = {
nodes: {
getNode: jest.fn(() => childNode),
},
};
new NodeClass({ name: 'E2E-DashboardAPI', host: 'grafana', port: 3000 }, RED, node, 'dashboardapi');
expect(SpecificClass).toHaveBeenCalledWith(
expect.objectContaining({
general: expect.objectContaining({
name: 'E2E-DashboardAPI',
id: 'dashboard-node-id',
}),
functionality: expect.objectContaining({
softwareType: 'dashboardapi',
role: 'auto ui generator',
}),
grafanaConnector: expect.objectContaining({
host: 'grafana',
port: 3000,
}),
}),
);
const send = jest.fn();
const done = jest.fn();
await handlers.input({ topic: 'registerChild', payload: 'measurement-e2e-node' }, send, done);
expect(RED.nodes.getNode).toHaveBeenCalledWith('measurement-e2e-node');
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
topic: 'create',
url: 'http://grafana:3000/api/dashboards/db',
method: 'POST',
payload: {
dashboard: { title: 'ok' },
folderId: 0,
overwrite: true,
},
}),
);
expect(done).toHaveBeenCalledWith();
});
it('falls back to the active flow when RED.nodes.getNode lacks source state', async () => {
const flowChildNode = {
source: {
config: {
general: { name: 'child' },
functionality: { softwareType: 'measurement' },
},
},
};
const { node, handlers } = createNodeHarness(flowChildNode);
const RED = {
nodes: {
getNode: jest.fn(() => ({ id: 'measurement-e2e-node' })),
},
};
new NodeClass({ name: 'E2E-DashboardAPI', host: 'grafana', port: 3000 }, RED, node, 'dashboardapi');
const send = jest.fn();
const done = jest.fn();
await handlers.input({ topic: 'registerChild', payload: 'measurement-e2e-node' }, send, done);
expect(node._flow.getNode).toHaveBeenCalledWith('measurement-e2e-node');
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
topic: 'create',
payload: {
dashboard: { title: 'ok' },
folderId: 0,
overwrite: true,
},
}),
);
expect(done).toHaveBeenCalledWith();
});
it('accepts a child config payload directly', async () => {
const { node, handlers } = createNodeHarness();
const RED = {
nodes: {
getNode: jest.fn(),
},
};
new NodeClass({ name: 'E2E-DashboardAPI', host: 'grafana', port: 3000 }, RED, node, 'dashboardapi');
const send = jest.fn();
const done = jest.fn();
await handlers.input(
{
topic: 'registerChild',
payload: {
config: {
general: { name: 'E2E-Level-Sensor' },
functionality: { softwareType: 'measurement' },
},
},
},
send,
done,
);
expect(RED.nodes.getNode).not.toHaveBeenCalled();
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
topic: 'create',
payload: {
dashboard: { title: 'ok' },
folderId: 0,
overwrite: true,
},
}),
);
expect(done).toHaveBeenCalledWith();
});
});