Compare commits
8 Commits
5f1dd7675c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
869ba4fca5 | ||
|
|
66b91883ac | ||
|
|
c5272fcc24 | ||
|
|
89d2260351 | ||
|
|
547333be7d | ||
|
|
b285d8e83a | ||
|
|
1ea4788848 | ||
|
|
c99a93f73b |
23
CLAUDE.md
Normal file
23
CLAUDE.md
Normal 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
71
config/dashboardapi.json
Normal 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
|
||||
}
|
||||
|
||||
2142
config/machine.json
2142
config/machine.json
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
2665
config/monster.json
2665
config/monster.json
File diff suppressed because it is too large
Load Diff
171
config/pumpingStation.json
Normal file
171
config/pumpingStation.json
Normal 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
97
config/reactor.json
Normal 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
71
config/settler.json
Normal 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
85
config/valve.json
Normal 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
|
||||
}
|
||||
|
||||
85
config/valveGroupControl.json
Normal file
85
config/valveGroupControl.json
Normal 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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
133
dashboardapi.js
133
dashboardapi.js
@@ -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
8
examples/README.md
Normal 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
6
examples/basic.flow.json
Normal 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
6
examples/edge.flow.json
Normal 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":[]}
|
||||
]
|
||||
6
examples/integration.flow.json
Normal file
6
examples/integration.flow.json
Normal 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
25
package.json
Normal 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
134
src/nodeClass.js
Normal 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
211
src/specificClass.js
Normal 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
12
test/README.md
Normal 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
0
test/basic/.gitkeep
Normal file
7
test/basic/structure-module-load.basic.test.js
Normal file
7
test/basic/structure-module-load.basic.test.js
Normal 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
81
test/dashboardapi.test.js
Normal 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
0
test/edge/.gitkeep
Normal file
11
test/edge/structure-examples-node-type.edge.test.js
Normal file
11
test/edge/structure-examples-node-type.edge.test.js
Normal 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
0
test/helpers/.gitkeep
Normal file
0
test/integration/.gitkeep
Normal file
0
test/integration/.gitkeep
Normal file
23
test/integration/structure-examples.integration.test.js
Normal file
23
test/integration/structure-examples.integration.test.js
Normal 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
179
test/nodeClass.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user