Compare commits
26 Commits
slice/34-w
...
slice/43-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de957cb971 | ||
|
|
533f74fe7e | ||
|
|
a16f526964 | ||
|
|
8afc6b9779 | ||
|
|
193f913eb1 | ||
|
|
41a20d4679 | ||
|
|
8a26e17780 | ||
|
|
3cd749bf37 | ||
|
|
70151e52ec | ||
|
|
b3972d4a2f | ||
|
|
3529c9f970 | ||
|
|
90536d631d | ||
|
|
c4f5b68c6a | ||
|
|
8bfc67c610 | ||
|
|
5d651b59ef | ||
|
|
5533293647 | ||
|
|
990a8c09ea | ||
| dc08c85409 | |||
| 2b745dfb51 | |||
| 3c8427ed7a | |||
| 8964b0b638 | |||
| a76f22281e | |||
| e5099de986 | |||
| 8639b02e6a | |||
| aac71eb129 | |||
| bdf87ffd67 |
@@ -150,7 +150,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"q\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"_results\")",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"q\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"_results\")",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reciruclation\" and r._field == \"q\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"_results\")",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reciruclation\" and r._field == \"q\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"_results\")",
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"recircN1\" and r._field == \"q\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"_results\")",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"recircN1\" and r._field == \"q\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"_results\")",
|
||||||
"refId": "C"
|
"refId": "C"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -282,7 +282,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"reactor1.S10.NH4+|NH3\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"reactor1.S10.NH4+|NH3\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -351,7 +351,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"iFlow\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"iFlow\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -420,7 +420,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"oFlowElement\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"oFlowElement\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -489,7 +489,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"oPLoss\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"oPLoss\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -558,7 +558,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"oOtr\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"oOtr\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -627,7 +627,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"reactor1.S7.O2\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"reactor1.S7.O2\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"dFactor\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"dFactor\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -777,7 +777,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"sludge\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"sludge\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -872,7 +872,7 @@
|
|||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"reactor2.S10.NH4+|NH3\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"reactor2.S10.NH4+|NH3\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -940,7 +940,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"iFlow\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"iFlow\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1009,7 +1009,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"oFlowElement\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"oFlowElement\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1089,7 +1089,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"oPLoss\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"oPLoss\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1158,7 +1158,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"oOtr\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"oOtr\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1227,7 +1227,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"reactor2.S7.O2\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"reactor2.S7.O2\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1302,7 +1302,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"dFactor\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"dFactor\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1375,7 +1375,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"sludge\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"sludge\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1470,7 +1470,7 @@
|
|||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"reactor3.S10.NH4+|NH3\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"reactor3.S10.NH4+|NH3\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "C"
|
"refId": "C"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1538,7 +1538,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"iFlow\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"iFlow\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1607,7 +1607,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"oFlowElement\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"oFlowElement\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1687,7 +1687,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"oPLoss\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"oPLoss\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1756,7 +1756,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"oOtr\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"oOtr\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1825,7 +1825,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"reactor3.S7.O2\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"reactor3.S7.O2\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1900,7 +1900,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"dFactor\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"dFactor\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1973,7 +1973,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"sludge\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"sludge\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -2068,7 +2068,7 @@
|
|||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NH4+|NH3\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NH4+|NH3\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "D"
|
"refId": "D"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -2136,7 +2136,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"iFlow\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"iFlow\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -2205,7 +2205,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"oFlowElement\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"oFlowElement\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -2285,7 +2285,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"oPLoss\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"oPLoss\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -2354,7 +2354,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"oOtr\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"oOtr\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -2423,7 +2423,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S7.O2\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S7.O2\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -2498,7 +2498,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"dFactor\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"dFactor\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -2571,7 +2571,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"sludge\" )\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"sludge\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -2681,7 +2681,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"influent.snh\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"influent NH4+|NH3\")",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"influent.snh\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"influent NH4+|NH3\")",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2690,7 +2690,7 @@
|
|||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NH4+|NH3\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"Reactor1 NH4+|NH3\")",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NH4+|NH3\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"Reactor1 NH4+|NH3\")",
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -2787,7 +2787,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => ( r._measurement == \"reactor1\" or r._measurement == \"reactor2\" or r._measurement == \"reactor3\" or r._measurement == \"reactor4\") and r._field == \"sludge\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> movingAverage(n: 50)",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => ( r._measurement == \"reactor1\" or r._measurement == \"reactor2\" or r._measurement == \"reactor3\" or r._measurement == \"reactor4\") and r._field == \"sludge\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> movingAverage(n: 50)",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -2869,7 +2869,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"influent.snh\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"influent.snh\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2878,7 +2878,7 @@
|
|||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"q\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"q\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "C"
|
"refId": "C"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -2979,7 +2979,7 @@
|
|||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NH4+|NH3\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NH4+|NH3\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2988,7 +2988,7 @@
|
|||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"q\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> last()",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"q\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
"refId": "C"
|
"refId": "C"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -3099,7 +3099,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"influent.sno\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"influent NH4+|NH3\")",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"influent.sno\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"influent NH4+|NH3\")",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3108,7 +3108,7 @@
|
|||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NO3-|NO2-\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"Reactor1 NH4+|NH3\")",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NO3-|NO2-\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"Reactor1 NH4+|NH3\")",
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -3214,7 +3214,7 @@
|
|||||||
"type": "influxdb",
|
"type": "influxdb",
|
||||||
"uid": "cdzg44tv250jkd"
|
"uid": "cdzg44tv250jkd"
|
||||||
},
|
},
|
||||||
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r.group == \"bio\" and r._field =~ /reactor1.S1/)\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n ",
|
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r.group == \"bio\" and r._field =~ /reactor1.S1/)\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n ",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
|
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
|
||||||
"targets": [
|
"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)",
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\")\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: count, createEmpty: false)",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
1042
config/machine.json
1042
config/machine.json
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,10 @@
|
|||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"builtIn": 1,
|
"builtIn": 1,
|
||||||
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
|
"datasource": {
|
||||||
|
"type": "grafana",
|
||||||
|
"uid": "-- Grafana --"
|
||||||
|
},
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"hide": true,
|
"hide": true,
|
||||||
"iconColor": "rgba(0, 211, 255, 1)",
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
@@ -17,91 +20,451 @@
|
|||||||
"id": null,
|
"id": null,
|
||||||
"links": [],
|
"links": [],
|
||||||
"panels": [
|
"panels": [
|
||||||
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Status", "type": "row" },
|
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"gridPos": {
|
||||||
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "purple", "value": null }] } }, "overrides": [] },
|
"h": 1,
|
||||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 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": "purple",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 6,
|
||||||
|
"x": 0,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "/.*/"
|
||||||
|
},
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none"
|
||||||
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mode\")\n |> last()", "refId": "A" }
|
{
|
||||||
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mode\")\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"title": "Mode",
|
"title": "Mode",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"meta": {
|
||||||
|
"emittedFields": []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"datasource": {
|
||||||
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } }, "overrides": [] },
|
"type": "influxdb",
|
||||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 },
|
"uid": "cdzg44tv250jkd"
|
||||||
"id": 3,
|
},
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
|
"fieldConfig": {
|
||||||
"targets": [
|
"defaults": {
|
||||||
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"scaling\")\n |> last()", "refId": "A" }
|
"thresholds": {
|
||||||
],
|
"mode": "absolute",
|
||||||
"title": "Scaling",
|
"steps": [
|
||||||
"type": "stat"
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"color": "yellow",
|
||||||
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 5 }, { "color": "red", "value": 15 }] } }, "overrides": [] },
|
"value": 5
|
||||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 },
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 6,
|
||||||
|
"x": 12,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area"
|
||||||
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"absDistFromPeak\")\n |> last()", "refId": "A" }
|
{
|
||||||
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"absDistFromPeak\")\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"title": "Abs Dist Peak",
|
"title": "Abs Dist Peak",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"meta": {
|
||||||
|
"emittedFields": [
|
||||||
|
"absDistFromPeak"
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"datasource": {
|
||||||
"fieldConfig": { "defaults": { "unit": "percent", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 10 }, { "color": "red", "value": 25 }] } }, "overrides": [] },
|
"type": "influxdb",
|
||||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 },
|
"uid": "cdzg44tv250jkd"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percent",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "yellow",
|
||||||
|
"value": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 25
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 6,
|
||||||
|
"x": 18,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area"
|
||||||
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"relDistFromPeak\")\n |> last()", "refId": "A" }
|
{
|
||||||
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"relDistFromPeak\")\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"title": "Rel Dist Peak",
|
"title": "Rel Dist Peak",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"meta": {
|
||||||
|
"emittedFields": []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, "id": 6, "title": "Totals", "type": "row" },
|
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"gridPos": {
|
||||||
"fieldConfig": { "defaults": { "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
|
"h": 1,
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 },
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 5
|
||||||
|
},
|
||||||
|
"id": 6,
|
||||||
|
"title": "Totals",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "influxdb",
|
||||||
|
"uid": "cdzg44tv250jkd"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"drawStyle": "line",
|
||||||
|
"lineWidth": 2,
|
||||||
|
"fillOpacity": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byRegexp",
|
||||||
|
"options": ".+\\.min$"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.lineStyle",
|
||||||
|
"value": {
|
||||||
|
"fill": "dash",
|
||||||
|
"dash": [
|
||||||
|
10,
|
||||||
|
10
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"mode": "fixed",
|
||||||
|
"fixedColor": "orange"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byRegexp",
|
||||||
|
"options": ".+\\.max$"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.lineStyle",
|
||||||
|
"value": {
|
||||||
|
"fill": "dash",
|
||||||
|
"dash": [
|
||||||
|
10,
|
||||||
|
10
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"mode": "fixed",
|
||||||
|
"fixedColor": "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 6
|
||||||
|
},
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"targets": [
|
"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|flow/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
|
{
|
||||||
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /predicted_flow|flow/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"title": "Total Flow",
|
"title": "Total Flow",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"meta": {
|
||||||
|
"emittedFields": [
|
||||||
|
"flow.total",
|
||||||
|
"flow.group"
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"datasource": {
|
||||||
"fieldConfig": { "defaults": { "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
|
"type": "influxdb",
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 },
|
"uid": "cdzg44tv250jkd"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"drawStyle": "line",
|
||||||
|
"lineWidth": 2,
|
||||||
|
"fillOpacity": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byRegexp",
|
||||||
|
"options": ".+\\.min$"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.lineStyle",
|
||||||
|
"value": {
|
||||||
|
"fill": "dash",
|
||||||
|
"dash": [
|
||||||
|
10,
|
||||||
|
10
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"mode": "fixed",
|
||||||
|
"fixedColor": "orange"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byRegexp",
|
||||||
|
"options": ".+\\.max$"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.lineStyle",
|
||||||
|
"value": {
|
||||||
|
"fill": "dash",
|
||||||
|
"dash": [
|
||||||
|
10,
|
||||||
|
10
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"mode": "fixed",
|
||||||
|
"fixedColor": "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 6
|
||||||
|
},
|
||||||
"id": 8,
|
"id": 8,
|
||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /predicted_power|power/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
|
{
|
||||||
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /predicted_power|power/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"title": "Total Power",
|
"title": "Total Power",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"meta": {
|
||||||
|
"emittedFields": [
|
||||||
|
"power.total",
|
||||||
|
"power.group"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"schemaVersion": 39,
|
"schemaVersion": 39,
|
||||||
"tags": ["EVOLV", "machineGroup", "template"],
|
"tags": [
|
||||||
|
"EVOLV",
|
||||||
|
"machineGroup",
|
||||||
|
"template"
|
||||||
|
],
|
||||||
"templating": {
|
"templating": {
|
||||||
"list": [
|
"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": "dbase",
|
||||||
{ "name": "bucket", "type": "custom", "query": "lvl2", "current": { "text": "lvl2", "value": "lvl2", "selected": false }, "options": [{ "text": "lvl2", "value": "lvl2", "selected": true }] }
|
"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
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"time": { "from": "now-6h", "to": "now" },
|
{
|
||||||
|
"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": "",
|
"timezone": "",
|
||||||
"title": "template",
|
"title": "template",
|
||||||
"uid": null,
|
"uid": null,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"id": 2,
|
"id": 2,
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mAbs\")\n |> last()", "refId": "A" }
|
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mAbs\")\n |> group(columns:[\"_field\"])\n |> last()", "refId": "A" }
|
||||||
],
|
],
|
||||||
"title": "mAbs (current)",
|
"title": "mAbs (current)",
|
||||||
"type": "stat"
|
"type": "stat"
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"id": 3,
|
"id": 3,
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "showThresholdLabels": false, "showThresholdMarkers": true },
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "showThresholdLabels": false, "showThresholdMarkers": true },
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mPercent\")\n |> last()", "refId": "A" }
|
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mPercent\")\n |> group(columns:[\"_field\"])\n |> last()", "refId": "A" }
|
||||||
],
|
],
|
||||||
"title": "mPercent",
|
"title": "mPercent",
|
||||||
"type": "gauge"
|
"type": "gauge"
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"id": 4,
|
"id": 4,
|
||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"mAbs\" or r._field==\"mPercent\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
|
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"mAbs\" or r._field==\"mPercent\"))\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
|
||||||
],
|
],
|
||||||
"title": "mAbs over Time",
|
"title": "mAbs over Time",
|
||||||
"type": "timeseries"
|
"type": "timeseries"
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"id": 6,
|
"id": 6,
|
||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"mAbs\" or r._field==\"totalMinSmooth\" or r._field==\"totalMaxSmooth\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
|
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"mAbs\" or r._field==\"totalMinSmooth\" or r._field==\"totalMaxSmooth\"))\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
|
||||||
],
|
],
|
||||||
"title": "mAbs + Smooth Bounds",
|
"title": "mAbs + Smooth Bounds",
|
||||||
"type": "timeseries"
|
"type": "timeseries"
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
"id": 7,
|
"id": 7,
|
||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"totalMinValue\" or r._field==\"totalMaxValue\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
|
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"totalMinValue\" or r._field==\"totalMaxValue\"))\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
|
||||||
],
|
],
|
||||||
"title": "Absolute Min / Max",
|
"title": "Absolute Min / Max",
|
||||||
"type": "timeseries"
|
"type": "timeseries"
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"builtIn": 1,
|
"builtIn": 1,
|
||||||
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
|
"datasource": {
|
||||||
|
"type": "grafana",
|
||||||
|
"uid": "-- Grafana --"
|
||||||
|
},
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"hide": true,
|
"hide": true,
|
||||||
"iconColor": "rgba(0, 211, 255, 1)",
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
@@ -17,14 +20,42 @@
|
|||||||
"id": null,
|
"id": null,
|
||||||
"links": [],
|
"links": [],
|
||||||
"panels": [
|
"panels": [
|
||||||
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Realtime Sampling (Monster)", "type": "row" },
|
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"gridPos": {
|
||||||
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 1 },
|
"h": 1,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 1,
|
||||||
|
"title": "Realtime Sampling (Monster)",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "influxdb",
|
||||||
|
"uid": "cdzg44tv250jkd"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 5,
|
||||||
|
"w": 8,
|
||||||
|
"x": 0,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
"id": 2,
|
"id": 2,
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "/.*/"
|
||||||
|
},
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none"
|
||||||
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"query": "from(bucket: \"${bucket}\")\n |> range(start: -30d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"running\" or r._field==\"pulse\"))\n |> last()",
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: -30d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"running\" or r._field==\"pulse\"))\n |> group(columns:[\"_field\"])\n |> last()\n |> drop(columns:[\"_time\",\"_start\",\"_stop\"])",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -32,14 +63,32 @@
|
|||||||
"type": "stat"
|
"type": "stat"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"datasource": {
|
||||||
"fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] },
|
"type": "influxdb",
|
||||||
"gridPos": { "h": 9, "w": 16, "x": 8, "y": 1 },
|
"uid": "cdzg44tv250jkd"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 16,
|
||||||
|
"x": 8,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
}
|
||||||
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"bucketVol\" or r._field==\"sumPuls\" or r._field==\"q\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"bucketVol\" or r._field==\"sumPuls\" or r._field==\"q\"))\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -48,7 +97,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"schemaVersion": 39,
|
"schemaVersion": 39,
|
||||||
"tags": ["EVOLV", "monster", "template"],
|
"tags": [
|
||||||
|
"EVOLV",
|
||||||
|
"monster",
|
||||||
|
"template"
|
||||||
|
],
|
||||||
"templating": {
|
"templating": {
|
||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
@@ -56,30 +109,62 @@
|
|||||||
"type": "custom",
|
"type": "custom",
|
||||||
"label": "dbase",
|
"label": "dbase",
|
||||||
"query": "cdzg44tv250jkd",
|
"query": "cdzg44tv250jkd",
|
||||||
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false },
|
"current": {
|
||||||
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }],
|
"text": "cdzg44tv250jkd",
|
||||||
|
"value": "cdzg44tv250jkd",
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"text": "cdzg44tv250jkd",
|
||||||
|
"value": "cdzg44tv250jkd",
|
||||||
|
"selected": true
|
||||||
|
}
|
||||||
|
],
|
||||||
"hide": 2
|
"hide": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "measurement",
|
"name": "measurement",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"query": "template",
|
"query": "template",
|
||||||
"current": { "text": "template", "value": "template", "selected": false },
|
"current": {
|
||||||
"options": [{ "text": "template", "value": "template", "selected": true }]
|
"text": "template",
|
||||||
|
"value": "template",
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"text": "template",
|
||||||
|
"value": "template",
|
||||||
|
"selected": true
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bucket",
|
"name": "bucket",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"query": "lvl2",
|
"query": "lvl2",
|
||||||
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
|
"current": {
|
||||||
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
|
"text": "lvl2",
|
||||||
|
"value": "lvl2",
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"text": "lvl2",
|
||||||
|
"value": "lvl2",
|
||||||
|
"selected": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"time": { "from": "now-24h", "to": "now" },
|
"time": {
|
||||||
|
"from": "now-24h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
"timezone": "",
|
"timezone": "",
|
||||||
"title": "template",
|
"title": "template",
|
||||||
"uid": null,
|
"uid": null,
|
||||||
"version": 1
|
"version": 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"builtIn": 1,
|
"builtIn": 1,
|
||||||
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
|
"datasource": {
|
||||||
|
"type": "grafana",
|
||||||
|
"uid": "-- Grafana --"
|
||||||
|
},
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"hide": true,
|
"hide": true,
|
||||||
"iconColor": "rgba(0, 211, 255, 1)",
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
@@ -17,150 +20,557 @@
|
|||||||
"id": null,
|
"id": null,
|
||||||
"links": [],
|
"links": [],
|
||||||
"panels": [
|
"panels": [
|
||||||
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Status", "type": "row" },
|
{
|
||||||
|
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
|
||||||
|
"id": 1,
|
||||||
|
"title": "Status",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||||
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } }, "overrides": [] },
|
"fieldConfig": {
|
||||||
"gridPos": { "h": 4, "w": 5, "x": 0, "y": 1 },
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [{ "color": "blue", "value": null }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 },
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
|
"options": {
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "/.*/" },
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none"
|
||||||
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"direction\")\n |> last()", "refId": "A" }
|
{
|
||||||
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"direction\")\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"title": "Direction",
|
"title": "Direction",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"meta": { "emittedFields": ["direction"] }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||||
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 300 }, { "color": "red", "value": 600 }] } }, "overrides": [] },
|
"fieldConfig": {
|
||||||
"gridPos": { "h": 4, "w": 5, "x": 5, "y": 1 },
|
"defaults": {
|
||||||
|
"unit": "s",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "green", "value": null },
|
||||||
|
{ "color": "orange", "value": 300 },
|
||||||
|
{ "color": "red", "value": 600 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 },
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
"options": {
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"] },
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area"
|
||||||
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"timeleft\")\n |> last()", "refId": "A" }
|
{
|
||||||
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"timeleft\")\n |> group(columns:[\"_field\"])\n |> last()",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"title": "Time Left",
|
"title": "Time Left",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"meta": { "emittedFields": ["timeLeft"] }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||||
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "purple", "value": null }] } }, "overrides": [] },
|
"fieldConfig": {
|
||||||
"gridPos": { "h": 4, "w": 4, "x": 10, "y": 1 },
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [{ "color": "purple", "value": null }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 },
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
|
"options": {
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "/.*/" },
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none"
|
||||||
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"flowSource\")\n |> last()", "refId": "A" }
|
{
|
||||||
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"flowSource\")\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"title": "Flow Source",
|
"title": "Flow Source",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"meta": { "emittedFields": ["flowSource"] }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"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": [] },
|
"fieldConfig": {
|
||||||
"gridPos": { "h": 4, "w": 5, "x": 14, "y": 1 },
|
"defaults": {
|
||||||
"id": 5,
|
"unit": "lengthm",
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "showThresholdLabels": false, "showThresholdMarkers": true },
|
"thresholds": {
|
||||||
"targets": [
|
"mode": "absolute",
|
||||||
{ "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" }
|
"steps": [{ "color": "green", "value": null }]
|
||||||
],
|
}
|
||||||
"title": "Fill %",
|
|
||||||
"type": "gauge"
|
|
||||||
},
|
},
|
||||||
{
|
"overrides": []
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
},
|
||||||
"fieldConfig": { "defaults": { "unit": "m", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } }, "overrides": [] },
|
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 },
|
||||||
"gridPos": { "h": 4, "w": 5, "x": 19, "y": 1 },
|
|
||||||
"id": 6,
|
"id": 6,
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
"options": {
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"] },
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area"
|
||||||
|
},
|
||||||
"targets": [
|
"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" }
|
{
|
||||||
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.predicted\\.atequipment/)\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"title": "Level",
|
"title": "Level",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"meta": { "emittedFields": ["level"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 },
|
||||||
|
"id": 13,
|
||||||
|
"title": "Basin",
|
||||||
|
"type": "row"
|
||||||
},
|
},
|
||||||
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, "id": 7, "title": "Basin", "type": "row" },
|
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||||
"fieldConfig": { "defaults": { "unit": "m", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
|
"fieldConfig": {
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 },
|
"defaults": {
|
||||||
|
"unit": "lengthm",
|
||||||
|
"min": 0,
|
||||||
|
"max": {{heightBasin}},
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "#3a3a3a", "value": null },
|
||||||
|
{ "color": "semi-dark-grey", "value": {{outflowLevel}} },
|
||||||
|
{ "color": "blue", "value": {{dryRunLevel}} },
|
||||||
|
{ "color": "green", "value": {{inflowLevel}} },
|
||||||
|
{ "color": "orange", "value": {{highSafetyLevel}} },
|
||||||
|
{ "color": "red", "value": {{overflowLevel}} }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 20, "w": 4, "x": 0, "y": 6 },
|
||||||
|
"id": 16,
|
||||||
|
"options": {
|
||||||
|
"displayMode": "basic",
|
||||||
|
"orientation": "vertical",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "" },
|
||||||
|
"showThresholdLabels": true,
|
||||||
|
"showThresholdMarkers": true,
|
||||||
|
"showUnfilled": true,
|
||||||
|
"minVizWidth": 8,
|
||||||
|
"minVizHeight": 16,
|
||||||
|
"valueMode": "color",
|
||||||
|
"namePlacement": "auto"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.predicted\\.atequipment/)\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Water Level",
|
||||||
|
"type": "bargauge",
|
||||||
|
"meta": { "emittedFields": ["basinLevel"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"decimals": 2
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": { "id": "byRegexp", "options": "^(outflowLevel|inflowLevel|overflowLevel|heightBasin|dryRunLevel|highVolumeSafetyLevel|level)$" },
|
||||||
|
"properties": [{ "id": "unit", "value": "lengthm" }, { "id": "decimals", "value": 2 }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": { "id": "byRegexp", "options": "^(volume|maxVol|minVol|maxVolAtOverflow|minVolAtOutflow|minVolAtInflow)$" },
|
||||||
|
"properties": [{ "id": "unit", "value": "m3" }, { "id": "decimals", "value": 2 }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": { "id": "byRegexp", "options": "^volumePercent$" },
|
||||||
|
"properties": [{ "id": "unit", "value": "percent" }, { "id": "decimals", "value": 1 }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 20, "w": 6, "x": 4, "y": 6 },
|
||||||
|
"id": 17,
|
||||||
|
"options": {
|
||||||
|
"inlineEditing": false,
|
||||||
|
"showAdvancedTypes": true,
|
||||||
|
"panZoom": false,
|
||||||
|
"infinitePan": false,
|
||||||
|
"root": {
|
||||||
|
"name": "Basin",
|
||||||
|
"type": "frame",
|
||||||
|
"placement": { "left": 0, "top": 0, "right": 0, "bottom": 0 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "dark-green" } },
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"name": "Zone Spill",
|
||||||
|
"type": "rectangle",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": 6.32, "left": 2.5, "right": 2.5, "bottom": {{zb_spill}} },
|
||||||
|
"background": { "color": { "fixed": "rgba(229, 67, 67, 0.18)" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zone HighSafety",
|
||||||
|
"type": "rectangle",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{y_overflow}}, "left": 2.5, "right": 2.5, "bottom": {{zb_highSafety}} },
|
||||||
|
"background": { "color": { "fixed": "rgba(242, 165, 67, 0.16)" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zone Operating",
|
||||||
|
"type": "rectangle",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{y_highSafety}}, "left": 2.5, "right": 2.5, "bottom": {{zb_operating}} },
|
||||||
|
"background": { "color": { "fixed": "rgba(95, 179, 122, 0.14)" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zone Dead",
|
||||||
|
"type": "rectangle",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{y_outflow}}, "left": 2.5, "right": 2.5, "bottom": {{zb_dead}} },
|
||||||
|
"background": { "color": { "fixed": "rgba(128, 128, 128, 0.20)" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tank Outline",
|
||||||
|
"type": "rectangle",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": 6.32, "left": 2.5, "right": 2.5, "bottom": 6.32 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "#8a8a8a" }, "width": 2 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Line Overflow",
|
||||||
|
"type": "rectangle",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{y_overflow}}, "left": 2.5, "right": 2.5, "bottom": {{yb_overflow}} },
|
||||||
|
"background": { "color": { "fixed": "#e54343" } },
|
||||||
|
"border": { "color": { "fixed": "#e54343" }, "width": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Line HighSafety",
|
||||||
|
"type": "rectangle",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{y_highSafety}}, "left": 2.5, "right": 2.5, "bottom": {{yb_highSafety}} },
|
||||||
|
"background": { "color": { "fixed": "#f2a543" } },
|
||||||
|
"border": { "color": { "fixed": "#f2a543" }, "width": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Line Inflow",
|
||||||
|
"type": "rectangle",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{y_inflow}}, "left": 2.5, "right": 2.5, "bottom": {{yb_inflow}} },
|
||||||
|
"background": { "color": { "fixed": "#5fb37a" } },
|
||||||
|
"border": { "color": { "fixed": "#5fb37a" }, "width": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Line DryRun",
|
||||||
|
"type": "rectangle",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{y_dryRun}}, "left": 2.5, "right": 2.5, "bottom": {{yb_dryRun}} },
|
||||||
|
"background": { "color": { "fixed": "#5b9bd5" } },
|
||||||
|
"border": { "color": { "fixed": "#5b9bd5" }, "width": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Line Outflow",
|
||||||
|
"type": "rectangle",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{y_outflow}}, "left": 2.5, "right": 2.5, "bottom": {{yb_outflow}} },
|
||||||
|
"background": { "color": { "fixed": "#bfbfbf" } },
|
||||||
|
"border": { "color": { "fixed": "#bfbfbf" }, "width": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Label Overflow Name",
|
||||||
|
"type": "text",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{ty_overflow}}, "left": 15, "right": 53, "bottom": {{tyb_overflow}} },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 14, "align": "right", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Label HighSafety Name",
|
||||||
|
"type": "text",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{ty_highSafety}}, "left": 15, "right": 53, "bottom": {{tyb_highSafety}} },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "highSafety" }, "color": { "fixed": "#cf7e20" }, "size": 14, "align": "right", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Label Inflow Name",
|
||||||
|
"type": "text",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{ty_inflow}}, "left": 15, "right": 53, "bottom": {{tyb_inflow}} },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 14, "align": "right", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Label DryRun Name",
|
||||||
|
"type": "text",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{ty_dryRun}}, "left": 15, "right": 53, "bottom": {{tyb_dryRun}} },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 14, "align": "right", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Label Outflow Name",
|
||||||
|
"type": "text",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{ty_outflow}}, "left": 15, "right": 53, "bottom": {{tyb_outflow}} },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "right", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Value Overflow",
|
||||||
|
"type": "metric-value",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{ty_overflow}}, "left": 53, "right": 12, "bottom": {{tyb_overflow}} },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "field", "fixed": "", "field": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 14, "align": "left", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Value HighSafety",
|
||||||
|
"type": "metric-value",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{ty_highSafety}}, "left": 53, "right": 12, "bottom": {{tyb_highSafety}} },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "field", "fixed": "", "field": "highVolumeSafetyLevel" }, "color": { "fixed": "#cf7e20" }, "size": 14, "align": "left", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Value Inflow",
|
||||||
|
"type": "metric-value",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{ty_inflow}}, "left": 53, "right": 12, "bottom": {{tyb_inflow}} },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "field", "fixed": "", "field": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 14, "align": "left", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Value DryRun",
|
||||||
|
"type": "metric-value",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{ty_dryRun}}, "left": 53, "right": 12, "bottom": {{tyb_dryRun}} },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "field", "fixed": "", "field": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 14, "align": "left", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Value Outflow",
|
||||||
|
"type": "metric-value",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": {{ty_outflow}}, "left": 53, "right": 12, "bottom": {{tyb_outflow}} },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "field", "fixed": "", "field": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "left", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Header Rim",
|
||||||
|
"type": "text",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": 1, "left": 2.5, "right": 2.5, "bottom": 95 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "rim ({{heightBasin}} m)" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "center", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Footer Floor",
|
||||||
|
"type": "text",
|
||||||
|
"constraint": { "horizontal": "scale", "vertical": "scale" },
|
||||||
|
"placement": { "top": 95, "left": 2.5, "right": 2.5, "bottom": 1 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "floor (0.00 m)" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "center", "valign": "middle" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"outflowLevel\" or r._field==\"inflowLevel\" or r._field==\"overflowLevel\" or r._field==\"heightBasin\" or r._field==\"dryRunLevel\" or r._field==\"highVolumeSafetyLevel\" or r._field =~ /^level\\.predicted\\.atequipment/ or r._field =~ /^volume\\.predicted\\.atequipment/ or r._field =~ /^volumePercent\\.predicted\\.atequipment/))\n |> last()\n |> map(fn: (r) => ({ r with _field: if r._field =~ /^volumePercent\\.predicted/ then \"volumePercent\" else if r._field =~ /^volume\\.predicted/ then \"volume\" else if r._field =~ /^level\\.predicted/ then \"level\" else r._field, _time: 2020-01-01T00:00:00Z }))\n |> group()\n |> keep(columns:[\"_field\",\"_value\",\"_time\"])\n |> pivot(rowKey:[\"_time\"], columnKey:[\"_field\"], valueColumn:\"_value\")",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Tank Layout",
|
||||||
|
"type": "canvas",
|
||||||
|
"meta": { "emittedFields": ["basinLayout"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "lengthm",
|
||||||
|
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 10, "w": 14, "x": 10, "y": 6 },
|
||||||
"id": 8,
|
"id": 8,
|
||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
"options": {
|
||||||
|
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||||
|
"tooltip": { "mode": "multi" }
|
||||||
|
},
|
||||||
"targets": [
|
"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" }
|
{
|
||||||
|
"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 |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"title": "Level",
|
"title": "Level (over time)",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"meta": { "emittedFields": ["level"] }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||||
"fieldConfig": { "defaults": { "unit": "m\u00b3", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
|
"fieldConfig": {
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 },
|
"defaults": {
|
||||||
|
"unit": "m3",
|
||||||
|
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 10, "w": 14, "x": 10, "y": 16 },
|
||||||
"id": 9,
|
"id": 9,
|
||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
"options": {
|
||||||
"targets": [
|
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||||
{ "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" }
|
"tooltip": { "mode": "multi" }
|
||||||
],
|
},
|
||||||
"title": "Volume",
|
"targets": [
|
||||||
"type": "timeseries"
|
{
|
||||||
|
"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 |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Volume (over time)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"meta": { "emittedFields": ["volume"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 26 },
|
||||||
|
"id": 10,
|
||||||
|
"title": "Flow",
|
||||||
|
"type": "row"
|
||||||
},
|
},
|
||||||
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, "id": 10, "title": "Flow", "type": "row" },
|
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||||
"fieldConfig": { "defaults": { "unit": "m\u00b3/h", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
|
"fieldConfig": {
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 },
|
"defaults": {
|
||||||
|
"unit": "m3/h",
|
||||||
|
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 27 },
|
||||||
"id": 11,
|
"id": 11,
|
||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
"options": {
|
||||||
|
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||||
|
"tooltip": { "mode": "multi" }
|
||||||
|
},
|
||||||
"targets": [
|
"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" }
|
{
|
||||||
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^netFlowRate\\.(predicted|measured)\\.atequipment/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"title": "Net Flow Rate",
|
"title": "Net Flow Rate",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"meta": { "emittedFields": ["flow.net", "flow"] }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||||
"fieldConfig": { "defaults": { "unit": "m\u00b3/h", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
|
"fieldConfig": {
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 },
|
"defaults": {
|
||||||
|
"unit": "m3/h",
|
||||||
|
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 27 },
|
||||||
"id": 12,
|
"id": 12,
|
||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
"options": {
|
||||||
|
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||||
|
"tooltip": { "mode": "multi" }
|
||||||
|
},
|
||||||
"targets": [
|
"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" }
|
{
|
||||||
|
"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)\\.(upstream|in|out|overflow)/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"title": "Inflow + Outflow",
|
"title": "Inflow + Outflow",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
},
|
"meta": { "emittedFields": ["flow.in", "flow.out"] }
|
||||||
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, "id": 13, "title": "Configuration", "type": "row" },
|
|
||||||
{
|
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
|
||||||
"fieldConfig": { "defaults": { "unit": "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,
|
"schemaVersion": 39,
|
||||||
"tags": ["EVOLV", "pumpingStation", "template"],
|
"tags": ["EVOLV", "pumpingStation", "template"],
|
||||||
"templating": {
|
"templating": {
|
||||||
"list": [
|
"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": "dbase",
|
||||||
{ "name": "bucket", "type": "custom", "query": "lvl2", "current": { "text": "lvl2", "value": "lvl2", "selected": false }, "options": [{ "text": "lvl2", "value": "lvl2", "selected": true }] }
|
"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" },
|
"time": { "from": "now-6h", "to": "now" },
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"id": 2,
|
"id": 2,
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
||||||
"targets": [
|
"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" }
|
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^S_O/)\n |> group(columns:[\"_field\"])\n |> last()", "refId": "A" }
|
||||||
],
|
],
|
||||||
"title": "DO (S_O)",
|
"title": "DO (S_O)",
|
||||||
"type": "stat"
|
"type": "stat"
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"id": 3,
|
"id": 3,
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
||||||
"targets": [
|
"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" }
|
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^S_NH/)\n |> group(columns:[\"_field\"])\n |> last()", "refId": "A" }
|
||||||
],
|
],
|
||||||
"title": "NH\u2084 (S_NH)",
|
"title": "NH\u2084 (S_NH)",
|
||||||
"type": "stat"
|
"type": "stat"
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"id": 4,
|
"id": 4,
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
||||||
"targets": [
|
"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" }
|
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^S_NO/)\n |> group(columns:[\"_field\"])\n |> last()", "refId": "A" }
|
||||||
],
|
],
|
||||||
"title": "NO\u2083 (S_NO)",
|
"title": "NO\u2083 (S_NO)",
|
||||||
"type": "stat"
|
"type": "stat"
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
"id": 5,
|
"id": 5,
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
||||||
"targets": [
|
"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" }
|
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^X_TS/)\n |> group(columns:[\"_field\"])\n |> last()", "refId": "A" }
|
||||||
],
|
],
|
||||||
"title": "TSS (X_TS)",
|
"title": "TSS (X_TS)",
|
||||||
"type": "stat"
|
"type": "stat"
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
"id": 7,
|
"id": 7,
|
||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
|
||||||
"targets": [
|
"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" }
|
{ "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 |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
|
||||||
],
|
],
|
||||||
"title": "Core Process Signals",
|
"title": "Core Process Signals",
|
||||||
"type": "timeseries"
|
"type": "timeseries"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
|
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
|
||||||
"targets": [
|
"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)",
|
"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 |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"builtIn": 1,
|
"builtIn": 1,
|
||||||
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
|
"datasource": {
|
||||||
|
"type": "grafana",
|
||||||
|
"uid": "-- Grafana --"
|
||||||
|
},
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"hide": true,
|
"hide": true,
|
||||||
"iconColor": "rgba(0, 211, 255, 1)",
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
@@ -17,14 +20,42 @@
|
|||||||
"id": null,
|
"id": null,
|
||||||
"links": [],
|
"links": [],
|
||||||
"panels": [
|
"panels": [
|
||||||
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Realtime Valve", "type": "row" },
|
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"gridPos": {
|
||||||
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 1 },
|
"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,
|
"id": 2,
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "/.*/"
|
||||||
|
},
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none"
|
||||||
|
},
|
||||||
"targets": [
|
"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()",
|
"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 |> group(columns:[\"_field\"])\n |> last()\n |> drop(columns:[\"_time\",\"_start\",\"_stop\"])",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -32,23 +63,45 @@
|
|||||||
"type": "stat"
|
"type": "stat"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"datasource": {
|
||||||
"fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] },
|
"type": "influxdb",
|
||||||
"gridPos": { "h": 9, "w": 16, "x": 8, "y": 1 },
|
"uid": "cdzg44tv250jkd"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 16,
|
||||||
|
"x": 8,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
}
|
||||||
|
},
|
||||||
"targets": [
|
"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)",
|
"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 |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Flow + ΔP",
|
"title": "Flow + \u0394P",
|
||||||
"type": "timeseries"
|
"type": "timeseries"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"schemaVersion": 39,
|
"schemaVersion": 39,
|
||||||
"tags": ["EVOLV", "valve", "template"],
|
"tags": [
|
||||||
|
"EVOLV",
|
||||||
|
"valve",
|
||||||
|
"template"
|
||||||
|
],
|
||||||
"templating": {
|
"templating": {
|
||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
@@ -56,30 +109,62 @@
|
|||||||
"type": "custom",
|
"type": "custom",
|
||||||
"label": "dbase",
|
"label": "dbase",
|
||||||
"query": "cdzg44tv250jkd",
|
"query": "cdzg44tv250jkd",
|
||||||
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false },
|
"current": {
|
||||||
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }],
|
"text": "cdzg44tv250jkd",
|
||||||
|
"value": "cdzg44tv250jkd",
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"text": "cdzg44tv250jkd",
|
||||||
|
"value": "cdzg44tv250jkd",
|
||||||
|
"selected": true
|
||||||
|
}
|
||||||
|
],
|
||||||
"hide": 2
|
"hide": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "measurement",
|
"name": "measurement",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"query": "template",
|
"query": "template",
|
||||||
"current": { "text": "template", "value": "template", "selected": false },
|
"current": {
|
||||||
"options": [{ "text": "template", "value": "template", "selected": true }]
|
"text": "template",
|
||||||
|
"value": "template",
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"text": "template",
|
||||||
|
"value": "template",
|
||||||
|
"selected": true
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bucket",
|
"name": "bucket",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"query": "lvl2",
|
"query": "lvl2",
|
||||||
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
|
"current": {
|
||||||
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
|
"text": "lvl2",
|
||||||
|
"value": "lvl2",
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"text": "lvl2",
|
||||||
|
"value": "lvl2",
|
||||||
|
"selected": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"time": { "from": "now-6h", "to": "now" },
|
"time": {
|
||||||
|
"from": "now-6h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
"timezone": "",
|
"timezone": "",
|
||||||
"title": "template",
|
"title": "template",
|
||||||
"uid": null,
|
"uid": null,
|
||||||
"version": 1
|
"version": 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"builtIn": 1,
|
"builtIn": 1,
|
||||||
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
|
"datasource": {
|
||||||
|
"type": "grafana",
|
||||||
|
"uid": "-- Grafana --"
|
||||||
|
},
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"hide": true,
|
"hide": true,
|
||||||
"iconColor": "rgba(0, 211, 255, 1)",
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
@@ -17,38 +20,88 @@
|
|||||||
"id": null,
|
"id": null,
|
||||||
"links": [],
|
"links": [],
|
||||||
"panels": [
|
"panels": [
|
||||||
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Realtime Valve Group", "type": "row" },
|
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"gridPos": {
|
||||||
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 1 },
|
"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,
|
"id": 2,
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "/.*/"
|
||||||
|
},
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none"
|
||||||
|
},
|
||||||
"targets": [
|
"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()",
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: -30d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"mode\" or r._field==\"maxDeltaP\"))\n |> group(columns:[\"_field\"])\n |> last()\n |> drop(columns:[\"_time\",\"_start\",\"_stop\"])",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Mode / maxΔP (last)",
|
"title": "Mode / max\u0394P (last)",
|
||||||
"type": "stat"
|
"type": "stat"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
"datasource": {
|
||||||
"fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] },
|
"type": "influxdb",
|
||||||
"gridPos": { "h": 9, "w": 16, "x": 8, "y": 1 },
|
"uid": "cdzg44tv250jkd"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 16,
|
||||||
|
"x": 8,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
}
|
||||||
|
},
|
||||||
"targets": [
|
"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)",
|
"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 |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Flow + maxΔP",
|
"title": "Flow + max\u0394P",
|
||||||
"type": "timeseries"
|
"type": "timeseries"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"schemaVersion": 39,
|
"schemaVersion": 39,
|
||||||
"tags": ["EVOLV", "valveGroupControl", "template"],
|
"tags": [
|
||||||
|
"EVOLV",
|
||||||
|
"valveGroupControl",
|
||||||
|
"template"
|
||||||
|
],
|
||||||
"templating": {
|
"templating": {
|
||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
@@ -56,30 +109,62 @@
|
|||||||
"type": "custom",
|
"type": "custom",
|
||||||
"label": "dbase",
|
"label": "dbase",
|
||||||
"query": "cdzg44tv250jkd",
|
"query": "cdzg44tv250jkd",
|
||||||
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false },
|
"current": {
|
||||||
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }],
|
"text": "cdzg44tv250jkd",
|
||||||
|
"value": "cdzg44tv250jkd",
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"text": "cdzg44tv250jkd",
|
||||||
|
"value": "cdzg44tv250jkd",
|
||||||
|
"selected": true
|
||||||
|
}
|
||||||
|
],
|
||||||
"hide": 2
|
"hide": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "measurement",
|
"name": "measurement",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"query": "template",
|
"query": "template",
|
||||||
"current": { "text": "template", "value": "template", "selected": false },
|
"current": {
|
||||||
"options": [{ "text": "template", "value": "template", "selected": true }]
|
"text": "template",
|
||||||
|
"value": "template",
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"text": "template",
|
||||||
|
"value": "template",
|
||||||
|
"selected": true
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bucket",
|
"name": "bucket",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"query": "lvl2",
|
"query": "lvl2",
|
||||||
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
|
"current": {
|
||||||
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
|
"text": "lvl2",
|
||||||
|
"value": "lvl2",
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"text": "lvl2",
|
||||||
|
"value": "lvl2",
|
||||||
|
"selected": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"time": { "from": "now-6h", "to": "now" },
|
"time": {
|
||||||
|
"from": "now-6h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
"timezone": "",
|
"timezone": "",
|
||||||
"title": "template",
|
"title": "template",
|
||||||
"uid": null,
|
"uid": null,
|
||||||
"version": 1
|
"version": 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
protocol: { value: 'http' },
|
protocol: { value: 'http' },
|
||||||
host: { value: 'localhost' },
|
host: { value: 'localhost' },
|
||||||
port: { value: 3000 },
|
port: { value: 3000 },
|
||||||
|
folderTitle: { value: '' },
|
||||||
folderUid: { value: '' },
|
folderUid: { value: '' },
|
||||||
defaultBucket: { value: '' },
|
defaultBucket: { value: '' },
|
||||||
},
|
},
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node);
|
window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
['name', 'protocol', 'host', 'port', 'folderUid', 'defaultBucket'].forEach((field) => {
|
['name', 'protocol', 'host', 'port', 'folderTitle', 'folderUid', 'defaultBucket'].forEach((field) => {
|
||||||
const element = document.getElementById(`node-input-${field}`);
|
const element = document.getElementById(`node-input-${field}`);
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
node[field] = field === 'port' ? parseInt(element.value, 10) || 3000 : element.value || '';
|
node[field] = field === 'port' ? parseInt(element.value, 10) || 3000 : element.value || '';
|
||||||
@@ -87,9 +88,14 @@
|
|||||||
<input type="password" id="node-input-bearerToken" placeholder="encrypted at rest" style="width:70%;" />
|
<input type="password" id="node-input-bearerToken" placeholder="encrypted at rest" style="width:70%;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-folderTitle"><i class="fa fa-folder"></i> Grafana Folder</label>
|
||||||
|
<input type="text" id="node-input-folderTitle" placeholder="folder name e.g. EVOLV — resolved/created by name" style="width:70%;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-folderUid"><i class="fa fa-folder-open"></i> Grafana Folder UID</label>
|
<label for="node-input-folderUid"><i class="fa fa-folder-open"></i> Grafana Folder UID</label>
|
||||||
<input type="text" id="node-input-folderUid" placeholder="optional — empty = General folder" style="width:70%;" />
|
<input type="text" id="node-input-folderUid" placeholder="optional fallback — leave empty when Folder name is set" style="width:70%;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -1,6 +1,70 @@
|
|||||||
[
|
[
|
||||||
{"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_tab",
|
||||||
{"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"]]},
|
"type": "tab",
|
||||||
{"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":[]}
|
"label": "dashboardAPI basic — measurement → Grafana",
|
||||||
|
"disabled": false,
|
||||||
|
"info": "Demonstrates the round-trip:\n- inject simulates a child.register message from a measurement node\n- dashboardapi composes a Grafana dashboard for that child\n- http request posts the dashboard to Grafana\n- debug shows the HTTP response\n\nConfigure the dashboardapi node with your Grafana host/port + bearer token\n(encrypted via Node-RED credentials). Default targets http://grafana:3000\nfrom inside the Docker compose stack."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dashboardAPI_basic_node",
|
||||||
|
"type": "dashboardapi",
|
||||||
|
"z": "dashboardAPI_basic_tab",
|
||||||
|
"name": "dashboardAPI",
|
||||||
|
"protocol": "http",
|
||||||
|
"host": "grafana",
|
||||||
|
"port": 3000,
|
||||||
|
"folderUid": "",
|
||||||
|
"defaultBucket": "telemetry",
|
||||||
|
"x": 460,
|
||||||
|
"y": 200,
|
||||||
|
"wires": [["dashboardAPI_basic_http"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dashboardAPI_basic_inj",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "dashboardAPI_basic_tab",
|
||||||
|
"name": "simulate child.register (measurement)",
|
||||||
|
"props": [
|
||||||
|
{ "p": "topic", "vt": "str" },
|
||||||
|
{ "p": "payload", "v": "{\"config\":{\"general\":{\"id\":\"meas-demo-001\",\"name\":\"FT-001 demo\"},\"functionality\":{\"softwareType\":\"measurement\",\"positionVsParent\":\"downstream\"}}}", "vt": "json" }
|
||||||
|
],
|
||||||
|
"topic": "child.register",
|
||||||
|
"x": 180,
|
||||||
|
"y": 200,
|
||||||
|
"wires": [["dashboardAPI_basic_node"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dashboardAPI_basic_http",
|
||||||
|
"type": "http request",
|
||||||
|
"z": "dashboardAPI_basic_tab",
|
||||||
|
"name": "POST /api/dashboards/db",
|
||||||
|
"method": "use",
|
||||||
|
"ret": "obj",
|
||||||
|
"paytoqs": "ignore",
|
||||||
|
"url": "",
|
||||||
|
"tls": "",
|
||||||
|
"persist": false,
|
||||||
|
"proxy": "",
|
||||||
|
"authType": "",
|
||||||
|
"senderr": false,
|
||||||
|
"x": 720,
|
||||||
|
"y": 200,
|
||||||
|
"wires": [["dashboardAPI_basic_dbg"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dashboardAPI_basic_dbg",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "dashboardAPI_basic_tab",
|
||||||
|
"name": "Grafana response",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "payload",
|
||||||
|
"targetType": "msg",
|
||||||
|
"x": 960,
|
||||||
|
"y": 200,
|
||||||
|
"wires": []
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dashboardAPI",
|
"name": "dashboardAPI",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "EVOLV Grafana dashboard generator (Node-RED node).",
|
"description": "EVOLV Grafana dashboard generator (Node-RED node).",
|
||||||
"main": "dashboardAPI.js",
|
"main": "dashboardAPI.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -22,14 +22,9 @@ function resolveChildNode(childId, ctx) {
|
|||||||
return runtimeNode || flowNode || null;
|
return runtimeNode || flowNode || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On child.register: build the dashboard graph (root + direct children) and
|
// Shared emit path used by both child.register (auto, deploy-driven) and
|
||||||
// emit one Grafana upsert HTTP request per dashboard on Port 0.
|
// regenerate-dashboard (manual). `trigger` distinguishes the two for logs.
|
||||||
function registerChild(source, msg, ctx) {
|
async function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
|
||||||
const childSource = resolveChildSource(msg.payload, ctx);
|
|
||||||
if (!childSource?.config) {
|
|
||||||
throw new Error('Missing or invalid child node');
|
|
||||||
}
|
|
||||||
|
|
||||||
const dashboards = source.generateDashboardsForGraph(childSource, {
|
const dashboards = source.generateDashboardsForGraph(childSource, {
|
||||||
includeChildren: Boolean(msg.includeChildren ?? true),
|
includeChildren: Boolean(msg.includeChildren ?? true),
|
||||||
});
|
});
|
||||||
@@ -39,7 +34,27 @@ function registerChild(source, msg, ctx) {
|
|||||||
const token = source.config?.grafanaConnector?.bearerToken;
|
const token = source.config?.grafanaConnector?.bearerToken;
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
// Resolve the folder by name (creating it if missing) so a rebuilt Grafana's
|
||||||
|
// fresh folder uid never strands the upserts on a stale pinned uid. Falls
|
||||||
|
// back to the configured folderUid on any failure.
|
||||||
|
const folderUid = typeof source.resolveFolderUid === 'function'
|
||||||
|
? await source.resolveFolderUid()
|
||||||
|
: (source.config?.grafanaConnector?.folderUid || undefined);
|
||||||
|
|
||||||
|
// Resolve the InfluxDB datasource uid by querying the target Grafana, then
|
||||||
|
// rewrite every panel/target/variable on each dashboard. Templates ship a
|
||||||
|
// hardcoded uid that only matches the Grafana they were authored against;
|
||||||
|
// without this rewrite a fresh Grafana renders every panel as
|
||||||
|
// "Datasource <uid> not found". Failure is non-fatal: rewriteDatasourceUid
|
||||||
|
// is a no-op when uid is empty, so panels keep their template uid.
|
||||||
|
const datasourceUid = typeof source.resolveDatasourceUid === 'function'
|
||||||
|
? await source.resolveDatasourceUid()
|
||||||
|
: '';
|
||||||
|
|
||||||
for (const dash of dashboards) {
|
for (const dash of dashboards) {
|
||||||
|
if (datasourceUid && typeof source.rewriteDatasourceUid === 'function') {
|
||||||
|
source.rewriteDatasourceUid(dash.dashboard, datasourceUid);
|
||||||
|
}
|
||||||
ctx.send({
|
ctx.send({
|
||||||
...msg,
|
...msg,
|
||||||
topic: 'create',
|
topic: 'create',
|
||||||
@@ -48,7 +63,7 @@ function registerChild(source, msg, ctx) {
|
|||||||
headers,
|
headers,
|
||||||
payload: source.buildUpsertRequest({
|
payload: source.buildUpsertRequest({
|
||||||
dashboard: dash.dashboard,
|
dashboard: dash.dashboard,
|
||||||
folderUid: source.config?.grafanaConnector?.folderUid || undefined,
|
folderUid: folderUid || undefined,
|
||||||
overwrite: true,
|
overwrite: true,
|
||||||
}),
|
}),
|
||||||
meta: {
|
meta: {
|
||||||
@@ -56,9 +71,73 @@ function registerChild(source, msg, ctx) {
|
|||||||
softwareType: dash.softwareType,
|
softwareType: dash.softwareType,
|
||||||
uid: dash.uid,
|
uid: dash.uid,
|
||||||
title: dash.title,
|
title: dash.title,
|
||||||
|
trigger,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (source.logger?.info) {
|
||||||
|
source.logger.info({
|
||||||
|
event: 'regen-emitted',
|
||||||
|
trigger,
|
||||||
|
dashboardApiId: ctx.node?.id,
|
||||||
|
childId: childSource?.config?.general?.id,
|
||||||
|
dashboardCount: dashboards.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { registerChild };
|
// On child.register: build the dashboard graph (root + direct children) and
|
||||||
|
// emit one Grafana upsert HTTP request per dashboard on Port 0.
|
||||||
|
//
|
||||||
|
// Diff-skip behavior (PRD F-1, S1 spike #32): if the latest flows:started
|
||||||
|
// payload's `diff` indicates that NEITHER the dashboardAPI itself NOR this
|
||||||
|
// child NOR its grandchildren changed, skip composition and log no-diff. The
|
||||||
|
// first call after startup (no cached diff yet) regenerates unconditionally.
|
||||||
|
async function registerChild(source, msg, ctx) {
|
||||||
|
const childSource = resolveChildSource(msg.payload, ctx);
|
||||||
|
if (!childSource?.config) {
|
||||||
|
throw new Error('Missing or invalid child node');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the child source for later manual regen (#41).
|
||||||
|
source.recordChild?.(childSource);
|
||||||
|
|
||||||
|
const subtreeIds = source.subtreeIdsFor(ctx.node?.id, childSource);
|
||||||
|
const changed = source.subtreeChanged(source.lastFlowsStartedDiff, subtreeIds);
|
||||||
|
if (!changed) {
|
||||||
|
if (source.logger?.info) {
|
||||||
|
source.logger.info({
|
||||||
|
event: 'regen-skipped',
|
||||||
|
outcome: 'no-diff',
|
||||||
|
trigger: 'child.register',
|
||||||
|
dashboardApiId: ctx.node?.id,
|
||||||
|
childId: childSource?.config?.general?.id,
|
||||||
|
subtreeSize: subtreeIds.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await emitDashboardsFor(source, childSource, ctx, msg, 'child.register');
|
||||||
|
}
|
||||||
|
|
||||||
|
// On regenerate-dashboard: re-emit dashboards for every cached child source,
|
||||||
|
// bypassing the diff predicate. Useful as an operator escape hatch when
|
||||||
|
// auto-regen missed an edge case or when the operator just wants to refresh.
|
||||||
|
async function regenerateDashboard(source, msg, ctx) {
|
||||||
|
const cached = source.cachedChildSources?.() || [];
|
||||||
|
if (source.logger?.info) {
|
||||||
|
source.logger.info({
|
||||||
|
event: 'manual-regen-requested',
|
||||||
|
trigger: 'manual',
|
||||||
|
dashboardApiId: ctx.node?.id,
|
||||||
|
cachedChildCount: cached.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const childSource of cached) {
|
||||||
|
await emitDashboardsFor(source, childSource, ctx, msg, 'manual');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerChild, regenerateDashboard };
|
||||||
|
|||||||
@@ -13,4 +13,10 @@ module.exports = [
|
|||||||
payloadSchema: { type: 'any' },
|
payloadSchema: { type: 'any' },
|
||||||
handler: handlers.registerChild,
|
handler: handlers.registerChild,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
topic: 'regenerate-dashboard',
|
||||||
|
aliases: ['regen'],
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
handler: handlers.regenerateDashboard,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -26,6 +26,29 @@ class nodeClass {
|
|||||||
|
|
||||||
this._attachInputHandler();
|
this._attachInputHandler();
|
||||||
this._attachCloseHandler();
|
this._attachCloseHandler();
|
||||||
|
this._attachLifecycleHook();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to Node-RED's `flows:started` event to cache the deploy diff so
|
||||||
|
// the child.register handler can decide whether *this* dashboardAPI's
|
||||||
|
// subtree was affected. Predicate documented in Gitea issue #32 spike.
|
||||||
|
_attachLifecycleHook() {
|
||||||
|
if (!this.RED?.events?.on) return;
|
||||||
|
this._flowsStartedListener = (payload) => {
|
||||||
|
const diff = payload?.diff || null;
|
||||||
|
this.source.lastFlowsStartedDiff = diff;
|
||||||
|
this.source.lastFlowsStartedAt = Date.now();
|
||||||
|
if (this.source?.logger?.debug) {
|
||||||
|
const summary = diff
|
||||||
|
? Object.fromEntries(
|
||||||
|
['added', 'changed', 'removed', 'rewired', 'linked', 'flowChanged']
|
||||||
|
.map((k) => [k, (diff[k] || []).length])
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
this.source.logger.debug({ event: 'flows:started', type: payload?.type, diff: summary });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.RED.events.on('flows:started', this._flowsStartedListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildConfig(uiConfig) {
|
_buildConfig(uiConfig) {
|
||||||
@@ -52,6 +75,7 @@ class nodeClass {
|
|||||||
host: uiConfig.host || 'localhost',
|
host: uiConfig.host || 'localhost',
|
||||||
port: Number(uiConfig.port || 3000),
|
port: Number(uiConfig.port || 3000),
|
||||||
bearerToken,
|
bearerToken,
|
||||||
|
folderTitle: uiConfig.folderTitle || '',
|
||||||
folderUid: uiConfig.folderUid || '',
|
folderUid: uiConfig.folderUid || '',
|
||||||
},
|
},
|
||||||
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',
|
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',
|
||||||
@@ -78,6 +102,10 @@ class nodeClass {
|
|||||||
|
|
||||||
_attachCloseHandler() {
|
_attachCloseHandler() {
|
||||||
this.node.on('close', (done) => {
|
this.node.on('close', (done) => {
|
||||||
|
if (this._flowsStartedListener && this.RED?.events?.off) {
|
||||||
|
this.RED.events.off('flows:started', this._flowsStartedListener);
|
||||||
|
this._flowsStartedListener = null;
|
||||||
|
}
|
||||||
if (typeof done === 'function') done();
|
if (typeof done === 'function') done();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,28 @@ function slugify(input) {
|
|||||||
.slice(0, 60);
|
.slice(0, 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map a node's lowercased softwareType to its Grafana template file in config/.
|
||||||
|
// Nodes report softwareType as the lowercased node name (e.g. 'rotatingmachine',
|
||||||
|
// 'machinegroupcontrol'), but several template files are camelCase and some node
|
||||||
|
// types share a template (rotatingMachine → machine, diffuser → aeration). The
|
||||||
|
// keys here are always lowercase; lookup lowercases the input first.
|
||||||
|
const TEMPLATE_FILE_BY_SOFTWARE_TYPE = {
|
||||||
|
rotatingmachine: 'machine.json',
|
||||||
|
machine: 'machine.json',
|
||||||
|
machinegroupcontrol: 'machineGroup.json',
|
||||||
|
machinegroup: 'machineGroup.json',
|
||||||
|
pumpingstation: 'pumpingStation.json',
|
||||||
|
valvegroupcontrol: 'valveGroupControl.json',
|
||||||
|
diffuser: 'aeration.json',
|
||||||
|
aeration: 'aeration.json',
|
||||||
|
measurement: 'measurement.json',
|
||||||
|
monster: 'monster.json',
|
||||||
|
reactor: 'reactor.json',
|
||||||
|
settler: 'settler.json',
|
||||||
|
valve: 'valve.json',
|
||||||
|
dashboardapi: 'dashboardapi.json',
|
||||||
|
};
|
||||||
|
|
||||||
function defaultBucketForPosition(positionVsParent) {
|
function defaultBucketForPosition(positionVsParent) {
|
||||||
const pos = String(positionVsParent || '').toLowerCase();
|
const pos = String(positionVsParent || '').toLowerCase();
|
||||||
if (pos === 'upstream') return 'lvl1';
|
if (pos === 'upstream') return 'lvl1';
|
||||||
@@ -25,6 +47,20 @@ function defaultBucketForPosition(positionVsParent) {
|
|||||||
return 'lvl2';
|
return 'lvl2';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace `{{name}}` placeholders in a raw JSON template string with values
|
||||||
|
// from `vars`. Unknown placeholders are left intact. Used to inject node-config
|
||||||
|
// derived constants (basin geometry, threshold y-positions) into a template
|
||||||
|
// before JSON.parse — so the resulting dashboard has concrete numbers in
|
||||||
|
// fieldConfig.thresholds and canvas element placements. Mustache-style braces
|
||||||
|
// keep these placeholders distinct from Grafana's own `${var}` dashboard
|
||||||
|
// variables (which are interpreted by Grafana at render time).
|
||||||
|
function substituteTemplateVars(rawJson, vars) {
|
||||||
|
if (!vars || !Object.keys(vars).length) return rawJson;
|
||||||
|
return rawJson.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (m, key) => (
|
||||||
|
Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : m
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
function updateTemplatingVar(dashboard, varName, value) {
|
function updateTemplatingVar(dashboard, varName, value) {
|
||||||
const list = dashboard?.templating?.list;
|
const list = dashboard?.templating?.list;
|
||||||
if (!Array.isArray(list)) return;
|
if (!Array.isArray(list)) return;
|
||||||
@@ -64,6 +100,12 @@ class DashboardApi {
|
|||||||
host: config?.grafanaConnector?.host || 'localhost',
|
host: config?.grafanaConnector?.host || 'localhost',
|
||||||
port: Number(config?.grafanaConnector?.port || 3000),
|
port: Number(config?.grafanaConnector?.port || 3000),
|
||||||
bearerToken: config?.grafanaConnector?.bearerToken || '',
|
bearerToken: config?.grafanaConnector?.bearerToken || '',
|
||||||
|
// folderTitle is the durable way to target a folder: Grafana folder
|
||||||
|
// uids change whenever the instance is rebuilt, so a pinned folderUid
|
||||||
|
// goes stale (every upsert then 400s "folder not found"). When set, the
|
||||||
|
// uid is resolved (and the folder created if absent) by name at emit
|
||||||
|
// time. folderUid stays supported as an explicit override / fallback.
|
||||||
|
folderTitle: config?.grafanaConnector?.folderTitle || '',
|
||||||
folderUid: config?.grafanaConnector?.folderUid || '',
|
folderUid: config?.grafanaConnector?.folderUid || '',
|
||||||
},
|
},
|
||||||
defaultBucket: config?.defaultBucket || '',
|
defaultBucket: config?.defaultBucket || '',
|
||||||
@@ -75,6 +117,20 @@ class DashboardApi {
|
|||||||
this.config.general.logging.logLevel,
|
this.config.general.logging.logLevel,
|
||||||
this.config.general.name
|
this.config.general.name
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Light state cache for manual regen (#41). Stores the latest child
|
||||||
|
// source object per child id so `regenerate-dashboard` can re-emit
|
||||||
|
// dashboards without waiting for children to re-register.
|
||||||
|
this._lastChildSources = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
recordChild(childSource) {
|
||||||
|
const id = childSource?.config?.general?.id;
|
||||||
|
if (id) this._lastChildSources.set(id, childSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedChildSources() {
|
||||||
|
return Array.from(this._lastChildSources.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
_templatesDir() {
|
_templatesDir() {
|
||||||
@@ -84,9 +140,9 @@ class DashboardApi {
|
|||||||
_templateFileForSoftwareType(softwareType) {
|
_templateFileForSoftwareType(softwareType) {
|
||||||
const st = String(softwareType || '').trim();
|
const st = String(softwareType || '').trim();
|
||||||
const candidates = [
|
const candidates = [
|
||||||
|
TEMPLATE_FILE_BY_SOFTWARE_TYPE[st.toLowerCase()],
|
||||||
`${st}.json`,
|
`${st}.json`,
|
||||||
`${st.toLowerCase()}.json`,
|
`${st.toLowerCase()}.json`,
|
||||||
st === 'machineGroupControl' ? 'machineGroup.json' : null,
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
for (const filename of candidates) {
|
for (const filename of candidates) {
|
||||||
@@ -98,29 +154,372 @@ class DashboardApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadTemplate(softwareType) {
|
loadTemplate(softwareType, templateVars = null) {
|
||||||
const templatePath = this._templateFileForSoftwareType(softwareType);
|
const templatePath = this._templateFileForSoftwareType(softwareType);
|
||||||
if (!templatePath) return null;
|
if (!templatePath) return null;
|
||||||
const raw = fs.readFileSync(templatePath, 'utf8');
|
let raw = fs.readFileSync(templatePath, 'utf8');
|
||||||
|
// Always substitute — falls back to per-softwareType defaults so the
|
||||||
|
// template is JSON-parseable even when no nodeConfig is provided (tests,
|
||||||
|
// smoke-loading, etc.). _templateVarsForNode returns {} for types that
|
||||||
|
// don't use placeholders, which is a no-op pass.
|
||||||
|
const vars = templateVars || this._templateVarsForNode(softwareType, null);
|
||||||
|
raw = substituteTemplateVars(raw, vars);
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-softwareType numeric vars baked into the template before JSON.parse.
|
||||||
|
// Today only pumpingStation needs this (basin geometry → bar-gauge thresholds
|
||||||
|
// and canvas y-positions). Other types return {} and skip substitution.
|
||||||
|
_templateVarsForNode(softwareType, nodeConfig) {
|
||||||
|
const st = String(softwareType || '').toLowerCase();
|
||||||
|
if (st !== 'pumpingstation') return {};
|
||||||
|
|
||||||
|
// configManager.buildConfig nests basin geometry under `basin.*` and
|
||||||
|
// safety percentages under `safety.*` (see generalFunctions/configManager).
|
||||||
|
const basin = nodeConfig?.basin || {};
|
||||||
|
const safety = nodeConfig?.safety || {};
|
||||||
|
const heightBasin = Number(basin.height) || 4;
|
||||||
|
const inflowLevel = Number(basin.inflowLevel) || 0;
|
||||||
|
const outflowLevel = Number(basin.outflowLevel) || 0;
|
||||||
|
const overflowLevel = Number(basin.overflowLevel) || heightBasin;
|
||||||
|
const dryRunPct = Number(safety.dryRunThresholdPercent) || 30;
|
||||||
|
const highPct = Number(safety.highVolumeSafetyThresholdPercent) || 90;
|
||||||
|
|
||||||
|
// Mirror specificClass._computeSafetyPoints derivation (pumpingStation).
|
||||||
|
const dryRunLevel = outflowLevel * (1 + dryRunPct / 100);
|
||||||
|
const highSafetyLevel = overflowLevel * (highPct / 100);
|
||||||
|
|
||||||
|
// Reference frame: 400 (logical w) x 760 (logical h) px. With every
|
||||||
|
// canvas element using `constraint: { horizontal: scale, vertical: scale }`,
|
||||||
|
// Grafana interprets placement values as PERCENTAGES of the panel size,
|
||||||
|
// not pixels — so the basin stretches to fill the card at any viewport
|
||||||
|
// and stays centered without letterboxing.
|
||||||
|
// Tank reference: rim at y=48px (6.32%), floor at y=712px (93.68%),
|
||||||
|
// centred vertically with 48px top/bottom margins. Margins are sized
|
||||||
|
// so the size-14 'rim (X m)' and 'floor (0.00 m)' captions fit with
|
||||||
|
// ~10 px clearance from the topmost/bottommost threshold line — labels
|
||||||
|
// can never collide with a line at any basin geometry.
|
||||||
|
const FRAME_W = 400, FRAME_H = 760;
|
||||||
|
const TANK_TOP = 48, TANK_BOT = 712, TANK_H = TANK_BOT - TANK_TOP;
|
||||||
|
const yp = (v) => +(v / FRAME_H * 100).toFixed(2);
|
||||||
|
const xp = (v) => +(v / FRAME_W * 100).toFixed(2);
|
||||||
|
const hp = (v) => +(v / FRAME_H * 100).toFixed(2);
|
||||||
|
const wp = (v) => +(v / FRAME_W * 100).toFixed(2);
|
||||||
|
const yFor = (v) => +(TANK_BOT - (v / heightBasin) * TANK_H).toFixed(2);
|
||||||
|
const tyFor = (yLine) => +(yLine - 8).toFixed(2); // centre 16px text on the line
|
||||||
|
|
||||||
|
let y_overflow = yFor(overflowLevel);
|
||||||
|
let y_highSafety = yFor(highSafetyLevel);
|
||||||
|
let y_inflow = yFor(inflowLevel);
|
||||||
|
let y_dryRun = yFor(dryRunLevel);
|
||||||
|
let y_outflow = yFor(outflowLevel);
|
||||||
|
|
||||||
|
// Enforce a minimum visual gap between adjacent threshold lines so labels
|
||||||
|
// can always sit cleanly between them — independent of how close the
|
||||||
|
// underlying physical thresholds are. Slight geometric distortion is
|
||||||
|
// acceptable: the tank visual conveys ORDERING and ZONE STRUCTURE, not
|
||||||
|
// exact-scale level measurement. Dashed/value labels carry the true
|
||||||
|
// numeric values.
|
||||||
|
const MIN_LINE_GAP = 28; // px (≈3.7% of 760-tall frame, > LABEL_H + 2)
|
||||||
|
const sorted = [
|
||||||
|
{ id: 'overflow', get: () => y_overflow, set: (v) => (y_overflow = v) },
|
||||||
|
{ id: 'highSafety', get: () => y_highSafety, set: (v) => (y_highSafety = v) },
|
||||||
|
{ id: 'inflow', get: () => y_inflow, set: (v) => (y_inflow = v) },
|
||||||
|
{ id: 'dryRun', get: () => y_dryRun, set: (v) => (y_dryRun = v) },
|
||||||
|
{ id: 'outflow', get: () => y_outflow, set: (v) => (y_outflow = v) },
|
||||||
|
].sort((a, b) => a.get() - b.get());
|
||||||
|
// Push down to enforce min gap (anchor: topmost line)
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
const minY = sorted[i - 1].get() + MIN_LINE_GAP;
|
||||||
|
if (sorted[i].get() < minY) sorted[i].set(minY);
|
||||||
|
}
|
||||||
|
// If the last (lowest) line went past the floor, shift the whole stack up.
|
||||||
|
const overshoot = sorted[sorted.length - 1].get() - TANK_BOT;
|
||||||
|
if (overshoot > 0) {
|
||||||
|
for (const item of sorted) item.set(item.get() - overshoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label y-positions: labels sit either ABOVE or BELOW their threshold
|
||||||
|
// line, never on it. Each label is offset by ABOVE_OFFSET=22 px above
|
||||||
|
// its line by default (16 px tall label + 6 px clear above the line).
|
||||||
|
// If two thresholds are too close together for both labels to fit ABOVE
|
||||||
|
// their lines (label of the lower one would cross the upper line), the
|
||||||
|
// lower one's label flips BELOW its line instead. With the current
|
||||||
|
// basin (dryRun=2% means dryRunLevel sits right on outflowLevel; high-
|
||||||
|
// Safety=98% puts it just under overflowLevel) this naturally puts
|
||||||
|
// highSafety BELOW and outflow BELOW.
|
||||||
|
const LABEL_H = 16;
|
||||||
|
const ABOVE_OFFSET = 22; // label_top = line_y - 22 (6 px clear above line)
|
||||||
|
const BELOW_OFFSET = 6; // label_top = line_y + 6 (6 px clear below line)
|
||||||
|
const MIN_DIST_FOR_ABOVE = 24; // if distance to upper line < this, try below
|
||||||
|
const lines = [
|
||||||
|
{ id: 'overflow', line: y_overflow },
|
||||||
|
{ id: 'highSafety', line: y_highSafety },
|
||||||
|
{ id: 'inflow', line: y_inflow },
|
||||||
|
{ id: 'dryRun', line: y_dryRun },
|
||||||
|
{ id: 'outflow', line: y_outflow },
|
||||||
|
].sort((a, b) => a.line - b.line);
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const prev = i > 0 ? lines[i - 1] : null;
|
||||||
|
const tooClose = prev && (lines[i].line - prev.line) < MIN_DIST_FOR_ABOVE;
|
||||||
|
if (tooClose) {
|
||||||
|
// Default to BELOW unless the label would be clipped by the tank
|
||||||
|
// floor (thresholds at the very bottom — dryRun=tiny% means
|
||||||
|
// dryRunLevel sits right on the floor). Then stack ABOVE the
|
||||||
|
// previous label instead, even if it slightly crowds its own line.
|
||||||
|
const belowY = lines[i].line + BELOW_OFFSET;
|
||||||
|
if (belowY + LABEL_H <= TANK_BOT) {
|
||||||
|
lines[i].y = belowY;
|
||||||
|
} else {
|
||||||
|
lines[i].y = prev.y + LABEL_H + 2; // stack above with 2 px gap
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines[i].y = lines[i].line - ABOVE_OFFSET;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ty = Object.fromEntries(lines.map((l) => [l.id, +l.y.toFixed(2)]));
|
||||||
|
|
||||||
|
// Canvas elements use `constraint: { horizontal: scale, vertical: scale }`
|
||||||
|
// with margin-style placement (top + bottom + left + right, all %s of the
|
||||||
|
// panel). Bottom = % from panel bottom, top = % from panel top. Width and
|
||||||
|
// height are derived as 100 - top - bottom, etc.
|
||||||
|
// We emit *all* placement margins precomputed so the JSON template stays
|
||||||
|
// declarative.
|
||||||
|
const LABEL_H_PCT = hp(16); // 16 px label height as % of frame
|
||||||
|
const LINE_H_PCT = hp(1); // 1 px line height as % of frame
|
||||||
|
const bMargin = (top, h) => +(100 - top - h).toFixed(2);
|
||||||
|
const lineBottom = (lineY) => +(100 - yp(lineY) - LINE_H_PCT).toFixed(2);
|
||||||
|
const labelBottom = (lblY) => +(100 - yp(lblY) - LABEL_H_PCT).toFixed(2);
|
||||||
|
return {
|
||||||
|
heightBasin: +heightBasin.toFixed(2),
|
||||||
|
outflowLevel: +outflowLevel.toFixed(3),
|
||||||
|
inflowLevel: +inflowLevel.toFixed(3),
|
||||||
|
overflowLevel: +overflowLevel.toFixed(3),
|
||||||
|
dryRunLevel: +dryRunLevel.toFixed(3),
|
||||||
|
highSafetyLevel: +highSafetyLevel.toFixed(3),
|
||||||
|
// Threshold line top margins (% from panel top)
|
||||||
|
y_overflow: yp(y_overflow),
|
||||||
|
y_highSafety: yp(y_highSafety),
|
||||||
|
y_inflow: yp(y_inflow),
|
||||||
|
y_dryRun: yp(y_dryRun),
|
||||||
|
y_outflow: yp(y_outflow),
|
||||||
|
// Threshold line bottom margins (% from panel bottom)
|
||||||
|
yb_overflow: lineBottom(y_overflow),
|
||||||
|
yb_highSafety: lineBottom(y_highSafety),
|
||||||
|
yb_inflow: lineBottom(y_inflow),
|
||||||
|
yb_dryRun: lineBottom(y_dryRun),
|
||||||
|
yb_outflow: lineBottom(y_outflow),
|
||||||
|
// Zone bottom margins (zones end at the next line below)
|
||||||
|
zb_spill: +(100 - yp(y_overflow)).toFixed(2), // ends at overflow line
|
||||||
|
zb_highSafety: +(100 - yp(y_highSafety)).toFixed(2), // ends at highSafety line
|
||||||
|
zb_operating: +(100 - yp(y_outflow)).toFixed(2), // ends at outflow line
|
||||||
|
zb_dead: +(100 - yp(TANK_BOT)).toFixed(2), // ends at floor
|
||||||
|
// Label top margins (% from panel top) and bottom margins (% from panel bottom)
|
||||||
|
ty_overflow: yp(ty.overflow),
|
||||||
|
ty_highSafety: yp(ty.highSafety),
|
||||||
|
ty_inflow: yp(ty.inflow),
|
||||||
|
ty_dryRun: yp(ty.dryRun),
|
||||||
|
ty_outflow: yp(ty.outflow),
|
||||||
|
tyb_overflow: labelBottom(ty.overflow),
|
||||||
|
tyb_highSafety: labelBottom(ty.highSafety),
|
||||||
|
tyb_inflow: labelBottom(ty.inflow),
|
||||||
|
tyb_dryRun: labelBottom(ty.dryRun),
|
||||||
|
tyb_outflow: labelBottom(ty.outflow),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect every `meta.emittedFields` declared by panels in a template.
|
||||||
|
// Used by #39's parent panel filter — a parent panel whose emittedFields
|
||||||
|
// are fully covered by its children's panels is removed.
|
||||||
|
collectEmittedFields(dashboard) {
|
||||||
|
const out = new Set();
|
||||||
|
for (const panel of dashboard?.panels || []) {
|
||||||
|
const fields = panel?.meta?.emittedFields;
|
||||||
|
if (Array.isArray(fields)) for (const f of fields) out.add(f);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
grafanaUpsertUrl() {
|
grafanaUpsertUrl() {
|
||||||
const { protocol, host, port } = this.config.grafanaConnector;
|
const { protocol, host, port } = this.config.grafanaConnector;
|
||||||
return `${protocol}://${host}:${port}/api/dashboards/db`;
|
return `${protocol}://${host}:${port}/api/dashboards/db`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
grafanaFoldersUrl() {
|
||||||
|
const { protocol, host, port } = this.config.grafanaConnector;
|
||||||
|
return `${protocol}://${host}:${port}/api/folders`;
|
||||||
|
}
|
||||||
|
|
||||||
|
grafanaDatasourcesUrl() {
|
||||||
|
const { protocol, host, port } = this.config.grafanaConnector;
|
||||||
|
return `${protocol}://${host}:${port}/api/datasources`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_grafanaJsonHeaders() {
|
||||||
|
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
|
||||||
|
const token = this.config.grafanaConnector.bearerToken;
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the target Grafana folder uid by NAME, creating the folder if it
|
||||||
|
// doesn't exist. This is the durable alternative to a pinned folderUid, which
|
||||||
|
// goes stale on every Grafana rebuild (the new instance hands the same-named
|
||||||
|
// folder a fresh uid, and every dashboard upsert then 400s "folder not
|
||||||
|
// found"). Resolution is done once per process and cached.
|
||||||
|
//
|
||||||
|
// Degradation contract: any failure (no fetch, network error, non-OK
|
||||||
|
// response) logs a warning and falls back to the configured folderUid, so the
|
||||||
|
// node is never worse off than the pinned-uid behavior it replaces.
|
||||||
|
async resolveFolderUid({ fetchImpl = globalThis.fetch } = {}) {
|
||||||
|
const gc = this.config.grafanaConnector;
|
||||||
|
const title = String(gc.folderTitle || '').trim();
|
||||||
|
// No title configured → legacy behavior: use the explicit uid (may be '').
|
||||||
|
if (!title) return gc.folderUid || '';
|
||||||
|
if (this._resolvedFolderUid) return this._resolvedFolderUid;
|
||||||
|
if (typeof fetchImpl !== 'function') {
|
||||||
|
this.logger.warn('resolveFolderUid: no fetch implementation available; using configured folderUid');
|
||||||
|
return gc.folderUid || '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const uid = await this._lookupOrCreateFolder(title, fetchImpl);
|
||||||
|
if (uid) {
|
||||||
|
this._resolvedFolderUid = uid;
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`resolveFolderUid failed (${err?.message || err}); using configured folderUid`);
|
||||||
|
}
|
||||||
|
return gc.folderUid || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async _lookupOrCreateFolder(title, fetchImpl) {
|
||||||
|
const url = this.grafanaFoldersUrl();
|
||||||
|
const headers = this._grafanaJsonHeaders();
|
||||||
|
|
||||||
|
const listRes = await fetchImpl(url, { method: 'GET', headers });
|
||||||
|
if (listRes?.ok) {
|
||||||
|
const folders = await listRes.json();
|
||||||
|
const match = Array.isArray(folders)
|
||||||
|
&& folders.find((f) => String(f?.title || '').trim().toLowerCase() === title.toLowerCase());
|
||||||
|
if (match?.uid) {
|
||||||
|
this.logger.info({ event: 'folder-resolved', outcome: 'found', title, uid: match.uid });
|
||||||
|
return match.uid;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`resolveFolderUid: GET /api/folders -> ${listRes?.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRes = await fetchImpl(url, { method: 'POST', headers, body: JSON.stringify({ title }) });
|
||||||
|
if (createRes?.ok) {
|
||||||
|
const created = await createRes.json();
|
||||||
|
this.logger.info({ event: 'folder-resolved', outcome: 'created', title, uid: created?.uid });
|
||||||
|
return created?.uid || '';
|
||||||
|
}
|
||||||
|
this.logger.warn(`resolveFolderUid: POST /api/folders -> ${createRes?.status}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the target Grafana InfluxDB datasource uid at push time. Templates
|
||||||
|
// ship with a hardcoded uid baked into every panel; that uid only matches the
|
||||||
|
// Grafana instance the templates were authored against. Any other Grafana
|
||||||
|
// (fresh laptop, VPS, rebuilt instance) renders the panels as
|
||||||
|
// "Datasource <uid> not found". Resolution is done once per process and
|
||||||
|
// cached.
|
||||||
|
//
|
||||||
|
// Degradation contract: any failure (no fetch, network error, non-OK
|
||||||
|
// response, no influxdb datasource present) returns '' and the caller leaves
|
||||||
|
// the template's baked-in uid alone. Worst-case behavior is unchanged from
|
||||||
|
// before this resolver existed.
|
||||||
|
async resolveDatasourceUid({ fetchImpl = globalThis.fetch } = {}) {
|
||||||
|
if (this._resolvedDatasourceUid) return this._resolvedDatasourceUid;
|
||||||
|
if (typeof fetchImpl !== 'function') {
|
||||||
|
this.logger.warn('resolveDatasourceUid: no fetch implementation available; leaving template uid intact');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const uid = await this._lookupInfluxDatasource(fetchImpl);
|
||||||
|
if (uid) {
|
||||||
|
this._resolvedDatasourceUid = uid;
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`resolveDatasourceUid failed (${err?.message || err}); leaving template uid intact`);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async _lookupInfluxDatasource(fetchImpl) {
|
||||||
|
const url = this.grafanaDatasourcesUrl();
|
||||||
|
const headers = this._grafanaJsonHeaders();
|
||||||
|
const res = await fetchImpl(url, { method: 'GET', headers });
|
||||||
|
if (!res?.ok) {
|
||||||
|
this.logger.warn(`resolveDatasourceUid: GET /api/datasources -> ${res?.status}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const list = await res.json();
|
||||||
|
const match = Array.isArray(list) && list.find((d) => String(d?.type || '').toLowerCase() === 'influxdb');
|
||||||
|
if (match?.uid) {
|
||||||
|
this.logger.info({ event: 'datasource-resolved', outcome: 'found', name: match.name, uid: match.uid });
|
||||||
|
return match.uid;
|
||||||
|
}
|
||||||
|
this.logger.warn('resolveDatasourceUid: no influxdb datasource on target Grafana');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite every influxdb datasource.uid on a dashboard (panels, nested row
|
||||||
|
// panels, panel.targets, templating variables) to `uid`. No-op for any
|
||||||
|
// datasource whose type isn't 'influxdb' (e.g. the '-- Grafana --' annotation
|
||||||
|
// datasource) or whose uid is a template variable reference (e.g.
|
||||||
|
// '${datasource}'). No-op when `uid` is falsy.
|
||||||
|
rewriteDatasourceUid(dashboard, uid) {
|
||||||
|
if (!uid || !dashboard) return;
|
||||||
|
const visit = (panels) => {
|
||||||
|
if (!Array.isArray(panels)) return;
|
||||||
|
for (const panel of panels) {
|
||||||
|
if (panel?.datasource && String(panel.datasource.type || '').toLowerCase() === 'influxdb'
|
||||||
|
&& typeof panel.datasource.uid === 'string' && !panel.datasource.uid.startsWith('$')) {
|
||||||
|
panel.datasource.uid = uid;
|
||||||
|
}
|
||||||
|
if (Array.isArray(panel?.targets)) {
|
||||||
|
for (const t of panel.targets) {
|
||||||
|
if (t?.datasource && String(t.datasource.type || '').toLowerCase() === 'influxdb'
|
||||||
|
&& typeof t.datasource.uid === 'string' && !t.datasource.uid.startsWith('$')) {
|
||||||
|
t.datasource.uid = uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(panel?.panels);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(dashboard.panels);
|
||||||
|
const tplList = dashboard?.templating?.list;
|
||||||
|
if (Array.isArray(tplList)) {
|
||||||
|
for (const v of tplList) {
|
||||||
|
if (v?.datasource && String(v.datasource.type || '').toLowerCase() === 'influxdb'
|
||||||
|
&& typeof v.datasource.uid === 'string' && !v.datasource.uid.startsWith('$')) {
|
||||||
|
v.datasource.uid = uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildDashboard({ nodeConfig, positionVsParent }) {
|
buildDashboard({ nodeConfig, positionVsParent }) {
|
||||||
const softwareType =
|
const softwareType =
|
||||||
nodeConfig?.functionality?.softwareType ||
|
nodeConfig?.functionality?.softwareType ||
|
||||||
nodeConfig?.functionality?.software_type ||
|
nodeConfig?.functionality?.software_type ||
|
||||||
'measurement';
|
'measurement';
|
||||||
const nodeId = nodeConfig?.general?.id || nodeConfig?.general?.name || softwareType;
|
const nodeId = nodeConfig?.general?.id || nodeConfig?.general?.name || softwareType;
|
||||||
const measurementName = `${softwareType}_${nodeId}`;
|
// Mirror outputUtils.formatMsg: telemetry is written under general.name when
|
||||||
|
// set, falling back to `<softwareType>_<id>`. The dashboard's _measurement var
|
||||||
|
// must match that exactly or every panel queries a non-existent series.
|
||||||
|
const measurementName =
|
||||||
|
nodeConfig?.general?.name || `${softwareType}_${nodeConfig?.general?.id || softwareType}`;
|
||||||
const title = nodeConfig?.general?.name || String(nodeId);
|
const title = nodeConfig?.general?.name || String(nodeId);
|
||||||
|
|
||||||
// Missing templates are treated as non-fatal: we skip only that dashboard.
|
// Missing templates are treated as non-fatal: we skip only that dashboard.
|
||||||
const dashboard = this.loadTemplate(softwareType);
|
const templateVars = this._templateVarsForNode(softwareType, nodeConfig);
|
||||||
|
const dashboard = this.loadTemplate(softwareType, templateVars);
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`);
|
this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`);
|
||||||
return null;
|
return null;
|
||||||
@@ -142,7 +541,7 @@ class DashboardApi {
|
|||||||
updateTemplatingVar(dashboard, 'measurement', measurementName);
|
updateTemplatingVar(dashboard, 'measurement', measurementName);
|
||||||
updateTemplatingVar(dashboard, 'bucket', bucket);
|
updateTemplatingVar(dashboard, 'bucket', bucket);
|
||||||
|
|
||||||
return { dashboard, uid, title, softwareType, nodeId, measurementName };
|
return { dashboard, uid, title, softwareType, nodeId, measurementName, bucket };
|
||||||
}
|
}
|
||||||
|
|
||||||
buildUpsertRequest({ dashboard, folderId, folderUid, overwrite = true }) {
|
buildUpsertRequest({ dashboard, folderId, folderUid, overwrite = true }) {
|
||||||
@@ -168,29 +567,125 @@ class DashboardApi {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Predicate from Gitea issue #32 spike (S1 findings). Given the diff payload
|
||||||
|
// from Node-RED's flows:started event and a set of node ids that constitute
|
||||||
|
// "my subtree", decides whether the subtree changed on this deploy.
|
||||||
|
// `null` diff (first deploy / startup) → always regen (safe default).
|
||||||
|
subtreeChanged(diff, subtreeIds) {
|
||||||
|
if (!diff) return true;
|
||||||
|
const mine = new Set(subtreeIds);
|
||||||
|
for (const field of ['added', 'changed', 'removed', 'rewired']) {
|
||||||
|
const arr = diff[field] || [];
|
||||||
|
if (arr.some((id) => mine.has(id))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect every node id in "this dashboardAPI + this child's full subtree" for
|
||||||
|
// the diff predicate. Recurses the whole registered-child tree (not just
|
||||||
|
// grandchildren) so a change anywhere below a wired root triggers a regen.
|
||||||
|
// `visited` guards cycles / diamond topologies.
|
||||||
|
subtreeIdsFor(dashboardApiNodeId, childSource) {
|
||||||
|
const ids = new Set();
|
||||||
|
if (dashboardApiNodeId) ids.add(dashboardApiNodeId);
|
||||||
|
this._collectSubtreeIds(childSource, ids, new Set());
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
_collectSubtreeIds(nodeSource, ids, visited) {
|
||||||
|
const id = nodeSource?.config?.general?.id;
|
||||||
|
if (id) {
|
||||||
|
if (visited.has(id)) return;
|
||||||
|
visited.add(id);
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
for (const { childSource } of this.extractChildren(nodeSource)) {
|
||||||
|
this._collectSubtreeIds(childSource, ids, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose a dashboard for a wired root and EVERY descendant in its registered-
|
||||||
|
// child tree. Operators wire only subtree roots; dashboardAPI recurses the
|
||||||
|
// parent-child relationships to discover the rest. Returns a flat, pre-order
|
||||||
|
// array (root first) of buildDashboard results.
|
||||||
generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) {
|
generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) {
|
||||||
if (!rootSource?.config) {
|
if (!rootSource?.config) {
|
||||||
this.logger.warn('generateDashboardsForGraph skipped: root source missing config');
|
this.logger.warn('generateDashboardsForGraph skipped: root source missing config');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootPosition = rootSource?.positionVsParent || rootSource?.config?.functionality?.positionVsParent;
|
const results = [];
|
||||||
const rootDash = this.buildDashboard({ nodeConfig: rootSource.config, positionVsParent: rootPosition });
|
this._composeNode(rootSource, includeChildren, results, new Set());
|
||||||
if (!rootDash) return [];
|
return results;
|
||||||
|
|
||||||
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)
|
// Recursively compose `nodeSource` then its descendants. Per-parent dedup and
|
||||||
if (children.length > 0) {
|
// links are applied at every level (each parent is deduped against / links to
|
||||||
rootDash.dashboard.links = Array.isArray(rootDash.dashboard.links) ? rootDash.dashboard.links : [];
|
// its own direct children). `visited` ensures one dashboard per node id even
|
||||||
|
// when the topology has cycles or diamonds.
|
||||||
|
_composeNode(nodeSource, includeChildren, results, visited) {
|
||||||
|
const nodeId = nodeSource?.config?.general?.id;
|
||||||
|
if (nodeId) {
|
||||||
|
if (visited.has(nodeId)) return null;
|
||||||
|
visited.add(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = nodeSource?.positionVsParent || nodeSource?.config?.functionality?.positionVsParent;
|
||||||
|
const nodeDash = this.buildDashboard({ nodeConfig: nodeSource.config, positionVsParent: position });
|
||||||
|
if (!nodeDash) return null;
|
||||||
|
results.push(nodeDash);
|
||||||
|
|
||||||
|
if (!includeChildren) return nodeDash;
|
||||||
|
|
||||||
|
const children = this.extractChildren(nodeSource);
|
||||||
|
const childDashes = [];
|
||||||
|
for (const { childSource } of children) {
|
||||||
|
const childDash = this._composeNode(childSource, includeChildren, results, visited);
|
||||||
|
if (childDash) childDashes.push(childDash);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._dedupParentPanels(nodeDash, childDashes);
|
||||||
|
this._linkToChildren(nodeDash, children);
|
||||||
|
// Inject the per-pump fan-out panels AFTER dedup so they survive: these
|
||||||
|
// panels intentionally aggregate child data onto the parent dashboard
|
||||||
|
// (the operator wants every pump on one MGC graph), which is exactly what
|
||||||
|
// the no-duplication rule strips elsewhere. Run last so nothing removes them.
|
||||||
|
this._injectMachineGroupPumpPanels(nodeDash, children);
|
||||||
|
|
||||||
|
return nodeDash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-data-duplication rule (PRD F-5, #39): remove a parent's panels whose
|
||||||
|
// emittedFields are fully covered by its direct children's panels, so the
|
||||||
|
// same series isn't rendered twice across the parent/child dashboards.
|
||||||
|
_dedupParentPanels(parentDash, childDashes) {
|
||||||
|
if (childDashes.length === 0 || !parentDash.dashboard) return;
|
||||||
|
|
||||||
|
const childCoveredFields = new Set();
|
||||||
|
for (const dash of childDashes) {
|
||||||
|
for (const f of this.collectEmittedFields(dash.dashboard)) childCoveredFields.add(f);
|
||||||
|
}
|
||||||
|
const before = parentDash.dashboard.panels.length;
|
||||||
|
parentDash.dashboard.panels = parentDash.dashboard.panels.filter((p) => {
|
||||||
|
if (p.type === 'row') return true; // never drop rows
|
||||||
|
const fields = p?.meta?.emittedFields;
|
||||||
|
if (!Array.isArray(fields) || fields.length === 0) return true; // no declaration, keep
|
||||||
|
return !fields.every((f) => childCoveredFields.has(f));
|
||||||
|
});
|
||||||
|
if (this.logger?.debug && before !== parentDash.dashboard.panels.length) {
|
||||||
|
this.logger.debug({
|
||||||
|
event: 'parent-panels-deduped',
|
||||||
|
before,
|
||||||
|
after: parentDash.dashboard.panels.length,
|
||||||
|
rootTitle: parentDash.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_linkToChildren(parentDash, children) {
|
||||||
|
if (children.length === 0 || !parentDash.dashboard) return;
|
||||||
|
|
||||||
|
parentDash.dashboard.links = Array.isArray(parentDash.dashboard.links) ? parentDash.dashboard.links : [];
|
||||||
for (const { childSource } of children) {
|
for (const { childSource } of children) {
|
||||||
const childConfig = childSource.config;
|
const childConfig = childSource.config;
|
||||||
const childSoftwareType = childConfig?.functionality?.softwareType || 'measurement';
|
const childSoftwareType = childConfig?.functionality?.softwareType || 'measurement';
|
||||||
@@ -198,7 +693,7 @@ class DashboardApi {
|
|||||||
const childUid = stableUid(`${childSoftwareType}:${childNodeId}`);
|
const childUid = stableUid(`${childSoftwareType}:${childNodeId}`);
|
||||||
const childTitle = childConfig?.general?.name || String(childNodeId);
|
const childTitle = childConfig?.general?.name || String(childNodeId);
|
||||||
|
|
||||||
rootDash.dashboard.links.push({
|
parentDash.dashboard.links.push({
|
||||||
type: 'link',
|
type: 'link',
|
||||||
title: childTitle,
|
title: childTitle,
|
||||||
url: `/d/${childUid}/${slugify(childTitle)}`,
|
url: `/d/${childUid}/${slugify(childTitle)}`,
|
||||||
@@ -210,7 +705,221 @@ class DashboardApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
// Software types that count as a "pump" child of a machine group. Mirrors the
|
||||||
|
// template-alias map: a rotatingMachine reports softwareType 'rotatingmachine'
|
||||||
|
// in production, 'machine' in tests / shared template.
|
||||||
|
static _PUMP_SOFTWARE_TYPES = new Set(['rotatingmachine', 'machine']);
|
||||||
|
|
||||||
|
// Replicate the measurement-name convention from outputUtils.formatMsg /
|
||||||
|
// buildDashboard so the dashboard queries the exact series each pump writes:
|
||||||
|
// `general.name` when set, else `<softwareType>_<id>`.
|
||||||
|
_measurementNameForConfig(config) {
|
||||||
|
const softwareType = config?.functionality?.softwareType || 'measurement';
|
||||||
|
return config?.general?.name || `${softwareType}_${config?.general?.id || softwareType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datasource block reused for injected panels. Pull it off an existing panel
|
||||||
|
// so the dashboard keeps a single influxdb datasource uid; fall back to the
|
||||||
|
// template's known uid if every panel was deduped away.
|
||||||
|
_datasourceFor(dashboard) {
|
||||||
|
const withDs = (dashboard.panels || []).find((p) => p?.datasource?.type === 'influxdb');
|
||||||
|
return withDs?.datasource || { type: 'influxdb', uid: 'cdzg44tv250jkd' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the per-pump + group-aggregate timeseries panels for a machineGroup
|
||||||
|
// dashboard. The operator asked for one graph each of pump % control, pump
|
||||||
|
// predicted flow, and pump predicted power, with the group total folded in,
|
||||||
|
// the resolved demand overlaid on the flow graph, and the flow-capacity
|
||||||
|
// envelope drawn as dashed min/max lines.
|
||||||
|
//
|
||||||
|
// Per-pump series live in each pump's OWN InfluxDB measurement (not the
|
||||||
|
// MGC's), so the queries are generated at compose time from the known child
|
||||||
|
// topology. Pump series are kept by `_measurement` (legend = pump name);
|
||||||
|
// group series are kept by `_field` and renamed via byName overrides.
|
||||||
|
_injectMachineGroupPumpPanels(parentDash, children) {
|
||||||
|
if (!parentDash?.dashboard) return;
|
||||||
|
const st = String(parentDash.softwareType || '').toLowerCase();
|
||||||
|
if (st !== 'machinegroupcontrol' && st !== 'machinegroup') return;
|
||||||
|
|
||||||
|
const pumps = (children || [])
|
||||||
|
.map(({ childSource }) => childSource?.config)
|
||||||
|
.filter((c) => c && DashboardApi._PUMP_SOFTWARE_TYPES.has(
|
||||||
|
String(c?.functionality?.softwareType || '').toLowerCase()))
|
||||||
|
.map((c) => ({ measurement: this._measurementNameForConfig(c), title: c?.general?.name || c?.general?.id }));
|
||||||
|
|
||||||
|
if (pumps.length === 0) return; // No pumps wired → leave the static totals.
|
||||||
|
|
||||||
|
const dashboard = parentDash.dashboard;
|
||||||
|
const datasource = this._datasourceFor(dashboard);
|
||||||
|
// The richer flow/power panels below supersede the static group-total
|
||||||
|
// panels — drop them so the same series isn't drawn twice.
|
||||||
|
dashboard.panels = (dashboard.panels || []).filter(
|
||||||
|
(p) => p.title !== 'Total Flow' && p.title !== 'Total Power');
|
||||||
|
|
||||||
|
const measFilter = pumps.map((p) => `r._measurement == "${p.measurement}"`).join(' or ');
|
||||||
|
const nextId = Math.max(0, ...dashboard.panels.map((p) => Number(p.id) || 0)) + 1;
|
||||||
|
|
||||||
|
dashboard.panels.push(
|
||||||
|
this._pumpControlPanel({ datasource, measFilter, id: nextId, y: 6 }),
|
||||||
|
this._pumpFlowPanel({ datasource, measFilter, id: nextId + 1, y: 14 }),
|
||||||
|
this._pumpPowerPanel({ datasource, measFilter, id: nextId + 2, y: 22 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Injected-panel builders ──────────────────────────────────────────────
|
||||||
|
// All three use `${bucket}` / `${measurement}` template vars (resolved by
|
||||||
|
// Grafana from the dashboard's templating list) plus literal pump measurement
|
||||||
|
// names. v.timeRangeStart/Stop/windowPeriod are Grafana-supplied.
|
||||||
|
|
||||||
|
_baseTsPanel({ datasource, id, y, title, targets, overrides = [], defaults = {} }) {
|
||||||
|
return {
|
||||||
|
datasource,
|
||||||
|
fieldConfig: {
|
||||||
|
defaults: { custom: { drawStyle: 'line', lineWidth: 2, fillOpacity: 5, showPoints: 'never' }, ...defaults },
|
||||||
|
overrides,
|
||||||
|
},
|
||||||
|
gridPos: { h: 8, w: 24, x: 0, y },
|
||||||
|
id,
|
||||||
|
options: { legend: { displayMode: 'list', placement: 'bottom' }, tooltip: { mode: 'multi' } },
|
||||||
|
targets,
|
||||||
|
title,
|
||||||
|
type: 'timeseries',
|
||||||
|
// Empty emittedFields: these panels intentionally duplicate child series
|
||||||
|
// and must never be removed by the no-duplication dedup pass.
|
||||||
|
meta: { emittedFields: [], dynamic: 'mgc-pump-fanout' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pump series kept by `_measurement` → one line per pump, legend = pump name.
|
||||||
|
// `field` is exact-matched by default; pass `regex:true` to match a 4-segment
|
||||||
|
// MeasurementContainer key whose childId varies per pump. rotatingMachine
|
||||||
|
// writes its own predictions under childId = node id (e.g.
|
||||||
|
// `flow.predicted.atequipment.<pumpId>`), NOT a fixed `default`, so the
|
||||||
|
// flow/power series must match the position prefix, not an exact key.
|
||||||
|
_perPumpTarget({ measFilter, field, refId, transform = '', regex = false }) {
|
||||||
|
const fieldFilter = regex ? `r._field =~ /${field}/` : `r._field == "${field}"`;
|
||||||
|
return {
|
||||||
|
refId,
|
||||||
|
query:
|
||||||
|
`from(bucket: "\${bucket}")\n` +
|
||||||
|
` |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n` +
|
||||||
|
` |> filter(fn:(r) => (${measFilter}) and ${fieldFilter})\n` +
|
||||||
|
` |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n` +
|
||||||
|
transform +
|
||||||
|
` |> keep(columns: ["_time", "_value", "_measurement"])`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group series kept by `_field` → legend = field name, renamed via byName
|
||||||
|
// overrides. `fields` is OR-joined into one query.
|
||||||
|
_groupFieldsTarget({ fields, refId }) {
|
||||||
|
const filter = fields.map((f) => `r._field == "${f}"`).join(' or ');
|
||||||
|
return {
|
||||||
|
refId,
|
||||||
|
query:
|
||||||
|
`from(bucket: "\${bucket}")\n` +
|
||||||
|
` |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n` +
|
||||||
|
` |> filter(fn:(r) => r._measurement == "\${measurement}" and (${filter}))\n` +
|
||||||
|
` |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n` +
|
||||||
|
` |> keep(columns: ["_time", "_value", "_field"])`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_byName(name, properties) {
|
||||||
|
return { matcher: { id: 'byName', options: name }, properties };
|
||||||
|
}
|
||||||
|
|
||||||
|
_pumpControlPanel({ datasource, measFilter, id, y }) {
|
||||||
|
// Two series per pump so an operator can see at a glance whether each pump
|
||||||
|
// actually moved to where the MGC told it:
|
||||||
|
// • realized position — the bare `ctrl` field (getCurrentPosition), solid.
|
||||||
|
// • commanded setpoint — `ctrl.predicted.atequipment.<pumpId>`, the % the
|
||||||
|
// pump computed from the MGC flow command (calcCtrl reverse curve),
|
||||||
|
// drawn dashed. childId varies per pump, so match the position prefix.
|
||||||
|
// Both are already 0..100 %, so they map straight onto a % axis — no scaling.
|
||||||
|
// Each series' `_measurement` is suffixed so the legend distinguishes the
|
||||||
|
// two lines per pump ("Pump A (realized)" vs "Pump A (setpoint)").
|
||||||
|
const label = (name) =>
|
||||||
|
` |> map(fn: (r) => ({ r with _measurement: r._measurement + " (${name})" }))\n`;
|
||||||
|
return this._baseTsPanel({
|
||||||
|
datasource, id, y,
|
||||||
|
title: 'Pump % Control',
|
||||||
|
defaults: { unit: 'percent', min: 0, max: 100 },
|
||||||
|
targets: [
|
||||||
|
this._perPumpTarget({ measFilter, field: 'ctrl', refId: 'A', transform: label('realized') }),
|
||||||
|
this._perPumpTarget({
|
||||||
|
measFilter, field: '^ctrl\\.predicted\\.atequipment\\.', refId: 'B',
|
||||||
|
regex: true, transform: label('setpoint'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
overrides: [{
|
||||||
|
matcher: { id: 'byRegexp', options: '.*\\(setpoint\\)' },
|
||||||
|
properties: [{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [6, 6] } }],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_pumpFlowPanel({ datasource, measFilter, id, y }) {
|
||||||
|
return this._baseTsPanel({
|
||||||
|
datasource, id, y,
|
||||||
|
title: 'Pump Predicted Flow vs Demand',
|
||||||
|
defaults: { unit: 'm3/h' },
|
||||||
|
targets: [
|
||||||
|
this._perPumpTarget({ measFilter, field: '^flow\\.predicted\\.atequipment\\.', refId: 'A', regex: true }),
|
||||||
|
this._groupFieldsTarget({
|
||||||
|
refId: 'B',
|
||||||
|
fields: ['atEquipment_predicted_flow', 'demandFlow', 'demandPct', 'flowCapacityMin', 'flowCapacityMax'],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
overrides: [
|
||||||
|
this._byName('atEquipment_predicted_flow', [
|
||||||
|
{ id: 'displayName', value: 'Total flow' },
|
||||||
|
{ id: 'custom.lineWidth', value: 3 },
|
||||||
|
]),
|
||||||
|
this._byName('demandFlow', [
|
||||||
|
{ id: 'displayName', value: 'Flow demand (setpoint)' },
|
||||||
|
{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [6, 6] } },
|
||||||
|
{ id: 'color', value: { mode: 'fixed', fixedColor: 'blue' } },
|
||||||
|
]),
|
||||||
|
this._byName('demandPct', [
|
||||||
|
{ id: 'displayName', value: 'Demand %' },
|
||||||
|
{ id: 'unit', value: 'percent' },
|
||||||
|
{ id: 'custom.axisPlacement', value: 'right' },
|
||||||
|
{ id: 'custom.axisLabel', value: '% control' },
|
||||||
|
{ id: 'color', value: { mode: 'fixed', fixedColor: 'purple' } },
|
||||||
|
]),
|
||||||
|
this._byName('flowCapacityMin', [
|
||||||
|
{ id: 'displayName', value: 'Capacity min' },
|
||||||
|
{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [10, 10] } },
|
||||||
|
{ id: 'custom.fillOpacity', value: 0 },
|
||||||
|
{ id: 'color', value: { mode: 'fixed', fixedColor: 'orange' } },
|
||||||
|
]),
|
||||||
|
this._byName('flowCapacityMax', [
|
||||||
|
{ id: 'displayName', value: 'Capacity max' },
|
||||||
|
{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [10, 10] } },
|
||||||
|
{ id: 'custom.fillOpacity', value: 0 },
|
||||||
|
{ id: 'color', value: { mode: 'fixed', fixedColor: 'red' } },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_pumpPowerPanel({ datasource, measFilter, id, y }) {
|
||||||
|
return this._baseTsPanel({
|
||||||
|
datasource, id, y,
|
||||||
|
title: 'Pump Predicted Power',
|
||||||
|
defaults: { unit: 'kwatt' },
|
||||||
|
targets: [
|
||||||
|
this._perPumpTarget({ measFilter, field: '^power\\.predicted\\.atequipment\\.', refId: 'A', regex: true }),
|
||||||
|
this._groupFieldsTarget({ refId: 'B', fields: ['atEquipment_predicted_power'] }),
|
||||||
|
],
|
||||||
|
overrides: [
|
||||||
|
this._byName('atEquipment_predicted_power', [
|
||||||
|
{ id: 'displayName', value: 'Total power' },
|
||||||
|
{ id: 'custom.lineWidth', value: 3 },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
test/_output-manifest.md
Normal file
68
test/_output-manifest.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# dashboardAPI output manifest
|
||||||
|
|
||||||
|
Per `.claude/rules/output-coverage.md`: every output on every layer, in every state.
|
||||||
|
|
||||||
|
## Port 0 (process — Grafana upsert messages)
|
||||||
|
|
||||||
|
Emitted by the command handler(s) after a `child.register` or `regenerate-dashboard` message. Shape is the same for both; `meta.trigger` distinguishes them.
|
||||||
|
|
||||||
|
| Key | Source method | Type | States tested | Test file |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `topic` | `handlers.emitDashboardsFor` | `'create'` (literal) | populated | `test/basic/slice41-manual-regen.basic.test.js` |
|
||||||
|
| `url` | `source.grafanaUpsertUrl()` | string (configured Grafana endpoint) | populated, default-config | `test/basic/slice34-credentials-and-folder.basic.test.js` |
|
||||||
|
| `method` | `handlers.emitDashboardsFor` | `'POST'` (literal) | populated | `test/basic/slice41-manual-regen.basic.test.js` |
|
||||||
|
| `headers.Accept` | `handlers.emitDashboardsFor` | `'application/json'` (literal) | populated | _via output manifest test below_ |
|
||||||
|
| `headers['Content-Type']` | `handlers.emitDashboardsFor` | `'application/json'` (literal) | populated | _via output manifest test below_ |
|
||||||
|
| `headers.Authorization` | `handlers.emitDashboardsFor` | `'Bearer <token>'` when configured; absent when not | populated, absent (degraded — no token) | `test/basic/slice43-output-manifest.basic.test.js` |
|
||||||
|
| `payload.dashboard` | `source.buildDashboard()` | object (Grafana dashboard JSON) | populated, byte-identical-on-repeat | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js` |
|
||||||
|
| `payload.overwrite` | `source.buildUpsertRequest()` | `true` (literal) | populated | `test/basic/slice34-credentials-and-folder.basic.test.js` |
|
||||||
|
| `payload.folderUid` | `handlers.emitDashboardsFor` → `source.resolveFolderUid()` (by-name lookup/create, cached; falls back to configured `folderUid`) → `source.buildUpsertRequest()` | resolved uid string when `folderTitle` set or `folderUid` configured; absent when both empty | populated (resolved/found, created, fallback), absent (degraded — empty config) | `test/basic/slice48-folder-resolve-by-name.basic.test.js`, `test/basic/slice34-credentials-and-folder.basic.test.js` |
|
||||||
|
| `payload.folderId` | `source.buildUpsertRequest()` | number when explicitly passed; absent otherwise | absent (default), populated (explicit) | `test/basic/slice34-credentials-and-folder.basic.test.js` |
|
||||||
|
| `meta.nodeId` | `handlers.emitDashboardsFor` | string (child node id) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
|
||||||
|
| `meta.softwareType` | `handlers.emitDashboardsFor` | string (child softwareType) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
|
||||||
|
| `meta.uid` | `handlers.emitDashboardsFor` | string (stableUid hash, deterministic) | populated, byte-identical | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js` |
|
||||||
|
| `meta.title` | `handlers.emitDashboardsFor` | string (child name or id) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
|
||||||
|
| `meta.trigger` | `handlers.emitDashboardsFor` | `'child.register'` or `'manual'` | both states | `test/basic/slice41-manual-regen.basic.test.js` |
|
||||||
|
|
||||||
|
**Degraded-state convention:** missing keys are **absent**, never set to `null`. The `http request` consumer treats absent headers/payload fields as defaults.
|
||||||
|
|
||||||
|
## Port 1 (InfluxDB telemetry)
|
||||||
|
|
||||||
|
dashboardAPI emits **nothing** on Port 1 by design — it has no measurements, no tick loop, no telemetry. Verified by absence: no `formatForInflux` import, no Port 1 wires in `examples/`.
|
||||||
|
|
||||||
|
## Port 2 (registration / control plumbing)
|
||||||
|
|
||||||
|
dashboardAPI is a **sink** for `child.register` messages, not a source — it does not register itself with any parent. Nothing emitted on Port 2.
|
||||||
|
|
||||||
|
## Structured log outputs
|
||||||
|
|
||||||
|
| Event | Level | Triggered by | Fields | Test |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `regen-emitted` | info | successful composition (auto or manual) | `event`, `trigger`, `dashboardApiId`, `childId`, `dashboardCount` | `test/basic/slice43-output-manifest.basic.test.js` |
|
||||||
|
| `regen-skipped` | info | diff predicate says subtree unchanged | `event`, `outcome: 'no-diff'`, `trigger: 'child.register'`, `dashboardApiId`, `childId`, `subtreeSize` | `test/basic/slice43-output-manifest.basic.test.js` |
|
||||||
|
| `manual-regen-requested` | info | `regenerate-dashboard` topic received | `event`, `trigger: 'manual'`, `dashboardApiId`, `cachedChildCount` | `test/basic/slice41-manual-regen.basic.test.js` |
|
||||||
|
| `parent-panels-deduped` | debug | no-data-duplication filter removed root panels | `event`, `before`, `after`, `rootTitle` | _covered by composition tests in slice39_ |
|
||||||
|
| `flows:started` | debug | Node-RED runtime emits flows:started | `event: 'flows:started'`, `type`, `diff` (count summary) | _covered by predicate tests in slice36_ |
|
||||||
|
|
||||||
|
## specificClass return shapes
|
||||||
|
|
||||||
|
| Method | Return shape | Populated states | Degraded states | Test |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `buildDashboard(opts)` | `{ dashboard, uid, title, softwareType, nodeId, measurementName, bucket }` or `null`; `measurementName` mirrors `outputUtils.formatMsg` (`general.name` \|\| `<softwareType>_<id>`) so the dashboard `_measurement` var matches the telemetry series; `bucket` is the resolved Influx bucket | success (name set + name empty/fallback) | `null` when no template for softwareType | `test/basic/slice43-output-manifest.basic.test.js`, `test/basic/slice46-measurement-name-parity.basic.test.js` |
|
||||||
|
| `generateDashboardsForGraph(root)` | flat pre-order array of `buildDashboard` results (root first, then full descendant subtree); per-parent dedup + links applied at every level; machineGroup roots additionally get per-pump fan-out panels injected (see below) | 0..N children, 3-level tree, diamond, cycle | empty array when root config missing | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js`, `test/basic/slice44-recursive-discovery.basic.test.js` |
|
||||||
|
| `_injectMachineGroupPumpPanels(parentDash, children)` | mutates an MGC dashboard in place: replaces the static Total Flow/Power panels with 3 timeseries panels (Pump % Control, Pump Predicted Flow vs Demand, Pump Predicted Power) whose queries are generated from the child-pump measurement names. Panels carry `meta.emittedFields: []` so they survive the dedup pass | MGC with ≥1 rotatingMachine child | no-op for non-MGC dashboards or MGC with zero pump children (static totals retained) | `test/basic/slice47-mgc-pump-panels.basic.test.js` |
|
||||||
|
| `subtreeChanged(diff, ids)` | boolean | id-in-diff, no-id-in-diff | null diff → true (cold start) | `test/basic/slice36-diff-predicate.basic.test.js` |
|
||||||
|
| `subtreeIdsFor(myId, child)` | Set\<string\> | myId + every id in the child's full subtree (recurses all levels, cycle-safe) | myId only when child has no children | `test/basic/slice36-diff-predicate.basic.test.js`, `test/basic/slice44-recursive-discovery.basic.test.js` |
|
||||||
|
| `collectEmittedFields(dashboard)` | Set\<string\> | populated dashboard | empty set for `null`/`{}`/`{panels:[]}` | `test/basic/slice37-emitted-fields.basic.test.js` |
|
||||||
|
| `cachedChildSources()` | array of child sources | 0..N cached | empty after construction | `test/basic/slice41-manual-regen.basic.test.js` |
|
||||||
|
|
||||||
|
## Anti-patterns enforced
|
||||||
|
|
||||||
|
- ❌ Emitting `{payload: null}` — `handlers.emitDashboardsFor` always builds `payload: { dashboard, overwrite, ... }`. Verified.
|
||||||
|
- ❌ Mixing absent vs null for optional fields — `folderUid` / `folderId` are **absent** when unconfigured, never `null`. Verified.
|
||||||
|
- ❌ Per-call token stamping — token is set on `headers.Authorization` when configured; absent when not. No empty-string sentinel.
|
||||||
|
- ❌ Tab id over-triggering in diff predicate — predicate only matches against dashboardAPI's own id + child + grandchildren, never tab ids. Verified.
|
||||||
|
|
||||||
|
## Migration plan applied
|
||||||
|
|
||||||
|
This manifest is created together with slice #43 — the new outputs added in slices #34–#42 are documented here. Other EVOLV nodes still need their own manifests; tracked in `IMPROVEMENTS_BACKLOG.md`.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
|
||||||
|
function makeChild(i, softwareType = 'measurement', positionVsParent = 'downstream') {
|
||||||
|
return {
|
||||||
|
child: {
|
||||||
|
config: {
|
||||||
|
general: { id: `child-${i}`, name: `Child ${i}` },
|
||||||
|
functionality: { softwareType, positionVsParent },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
softwareType,
|
||||||
|
position: positionVsParent,
|
||||||
|
registeredAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRoot(children) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const c of children) map.set(c.child.config.general.id, c);
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
general: { id: 'root-1', name: 'Root' },
|
||||||
|
functionality: { softwareType: 'dashboardapi', positionVsParent: 'atequipment' },
|
||||||
|
},
|
||||||
|
childRegistrationUtils: { registeredChildren: map },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('generateDashboardsForGraph composes 50 children in <500ms', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const children = Array.from({ length: 50 }, (_, i) => makeChild(i));
|
||||||
|
const root = makeRoot(children);
|
||||||
|
|
||||||
|
const t0 = process.hrtime.bigint();
|
||||||
|
const dashboards = api.generateDashboardsForGraph(root, { includeChildren: true });
|
||||||
|
const t1 = process.hrtime.bigint();
|
||||||
|
|
||||||
|
const durationMs = Number(t1 - t0) / 1e6;
|
||||||
|
assert.ok(durationMs < 500, `composition took ${durationMs.toFixed(1)}ms, expected <500ms`);
|
||||||
|
assert.ok(dashboards.length >= 1, 'should produce at least the root dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uids are unique across all generated dashboards (no collision risk)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const children = Array.from({ length: 30 }, (_, i) => makeChild(i, 'measurement'));
|
||||||
|
const root = makeRoot(children);
|
||||||
|
const dashboards = api.generateDashboardsForGraph(root);
|
||||||
|
const uids = dashboards.map((d) => d.uid);
|
||||||
|
const unique = new Set(uids);
|
||||||
|
assert.equal(unique.size, uids.length, `expected ${uids.length} unique uids, got ${unique.size}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byte-identical composition under repeat (idempotency)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const children = Array.from({ length: 5 }, (_, i) => makeChild(i));
|
||||||
|
const root = makeRoot(children);
|
||||||
|
const first = JSON.stringify(api.generateDashboardsForGraph(root).map((d) => d.dashboard));
|
||||||
|
const second = JSON.stringify(api.generateDashboardsForGraph(root).map((d) => d.dashboard));
|
||||||
|
assert.equal(first, second, 'two consecutive compositions should produce byte-identical JSON');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('root dashboard links to every child dashboard', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const children = Array.from({ length: 4 }, (_, i) => makeChild(i));
|
||||||
|
const root = makeRoot(children);
|
||||||
|
const dashboards = api.generateDashboardsForGraph(root);
|
||||||
|
const rootDash = dashboards[0].dashboard;
|
||||||
|
assert.ok(Array.isArray(rootDash.links), 'root dashboard should have links array');
|
||||||
|
assert.equal(rootDash.links.length, 4, 'one link per registered child');
|
||||||
|
});
|
||||||
73
test/basic/slice36-diff-predicate.basic.test.js
Normal file
73
test/basic/slice36-diff-predicate.basic.test.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
|
||||||
|
test('subtreeChanged: null diff → always regen (safe default for cold start)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
assert.equal(api.subtreeChanged(null, new Set(['a', 'b'])), true);
|
||||||
|
assert.equal(api.subtreeChanged(undefined, new Set(['a', 'b'])), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtreeChanged: empty diff arrays → no regen needed', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const diff = { added: [], changed: [], removed: [], rewired: [], linked: [], flowChanged: [] };
|
||||||
|
assert.equal(api.subtreeChanged(diff, new Set(['a', 'b'])), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtreeChanged: id in added → regen', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const diff = { added: ['x', 'b'], changed: [], removed: [], rewired: [] };
|
||||||
|
assert.equal(api.subtreeChanged(diff, new Set(['a', 'b'])), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtreeChanged: id in changed → regen', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const diff = { added: [], changed: ['a'], removed: [], rewired: [] };
|
||||||
|
assert.equal(api.subtreeChanged(diff, new Set(['a', 'b'])), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtreeChanged: only unrelated ids → no regen', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const diff = { added: ['z'], changed: ['y'], removed: ['x'], rewired: ['w'] };
|
||||||
|
assert.equal(api.subtreeChanged(diff, new Set(['a', 'b'])), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtreeChanged: tab id in diff but not in subtree → no regen', () => {
|
||||||
|
// Tab id over-triggering avoidance: when an unrelated tab changes, its
|
||||||
|
// tab id lands in changed/added but should not affect this dashboardAPI.
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const diff = { added: [], changed: ['unrelated_tab'], removed: [], rewired: [] };
|
||||||
|
assert.equal(api.subtreeChanged(diff, new Set(['dashboardApiId', 'childA'])), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtreeIdsFor: includes dashboardAPI id + child id + grandchild ids', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const grandchild = {
|
||||||
|
config: { general: { id: 'gc-1' }, functionality: { softwareType: 'measurement' } },
|
||||||
|
};
|
||||||
|
const grandchildEntry = { child: grandchild, position: 'downstream', softwareType: 'measurement' };
|
||||||
|
const child = {
|
||||||
|
config: { general: { id: 'child-1' }, functionality: { softwareType: 'pumpingStation' } },
|
||||||
|
childRegistrationUtils: {
|
||||||
|
registeredChildren: new Map([['gc-1', grandchildEntry]]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ids = api.subtreeIdsFor('dApi-1', child);
|
||||||
|
assert.equal(ids.has('dApi-1'), true);
|
||||||
|
assert.equal(ids.has('child-1'), true);
|
||||||
|
assert.equal(ids.has('gc-1'), true);
|
||||||
|
assert.equal(ids.size, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtreeIdsFor: handles child with no grandchildren', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const child = {
|
||||||
|
config: { general: { id: 'child-1' }, functionality: { softwareType: 'measurement' } },
|
||||||
|
};
|
||||||
|
const ids = api.subtreeIdsFor('dApi-1', child);
|
||||||
|
assert.equal(ids.size, 2);
|
||||||
|
assert.ok(ids.has('dApi-1') && ids.has('child-1'));
|
||||||
|
});
|
||||||
40
test/basic/slice37-emitted-fields.basic.test.js
Normal file
40
test/basic/slice37-emitted-fields.basic.test.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
|
||||||
|
test('rotatingMachine template panels declare meta.emittedFields', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const dash = api.loadTemplate('machine');
|
||||||
|
assert.ok(dash, 'template loaded');
|
||||||
|
const withFields = dash.panels.filter((p) => p?.meta?.emittedFields);
|
||||||
|
// 13 non-row panels in machine.json get annotated; row panels are skipped.
|
||||||
|
assert.ok(withFields.length >= 10, `expected ≥10 annotated panels, got ${withFields.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectEmittedFields aggregates fields across panels', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const dash = api.loadTemplate('machine');
|
||||||
|
const fields = api.collectEmittedFields(dash);
|
||||||
|
assert.ok(fields.has('ctrl'), 'ctrl field declared by Ctrl % panel');
|
||||||
|
assert.ok(fields.has('flow'), 'flow field declared by Flow panel');
|
||||||
|
assert.ok(fields.has('efficiency'), 'efficiency field declared by Efficiency panel');
|
||||||
|
assert.ok(fields.has('relDistFromPeak'), 'relDistFromPeak declared by Distance from Peak panel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectEmittedFields returns empty Set for template without meta', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
// measurement.json has no emittedFields metadata yet — its panels predate the annotation.
|
||||||
|
const dash = api.loadTemplate('measurement');
|
||||||
|
const fields = api.collectEmittedFields(dash);
|
||||||
|
assert.equal(fields.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectEmittedFields handles null/empty dashboard input gracefully', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
assert.equal(api.collectEmittedFields(null).size, 0);
|
||||||
|
assert.equal(api.collectEmittedFields({}).size, 0);
|
||||||
|
assert.equal(api.collectEmittedFields({ panels: [] }).size, 0);
|
||||||
|
});
|
||||||
43
test/basic/slice38-dashed-bounds.basic.test.js
Normal file
43
test/basic/slice38-dashed-bounds.basic.test.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
|
||||||
|
test('rotatingMachine template carries byRegexp dashed overrides for .min/.max', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const dash = api.loadTemplate('machine');
|
||||||
|
const ts = dash.panels.filter((p) => p.type === 'timeseries');
|
||||||
|
assert.ok(ts.length >= 1, 'has at least one timeseries panel');
|
||||||
|
|
||||||
|
for (const panel of ts) {
|
||||||
|
const overrides = panel?.fieldConfig?.overrides || [];
|
||||||
|
const minOv = overrides.find(
|
||||||
|
(o) => o.matcher?.id === 'byRegexp' && /\.min\$/.test(o.matcher?.options || '')
|
||||||
|
);
|
||||||
|
const maxOv = overrides.find(
|
||||||
|
(o) => o.matcher?.id === 'byRegexp' && /\.max\$/.test(o.matcher?.options || '')
|
||||||
|
);
|
||||||
|
assert.ok(minOv, `panel "${panel.title}" missing .min override`);
|
||||||
|
assert.ok(maxOv, `panel "${panel.title}" missing .max override`);
|
||||||
|
|
||||||
|
const lineStyle = minOv.properties.find((p) => p.id === 'custom.lineStyle');
|
||||||
|
assert.equal(lineStyle?.value?.fill, 'dash', '.min override sets dashed lineStyle');
|
||||||
|
assert.deepEqual(lineStyle?.value?.dash, [10, 10], '.min override sets dash pattern [10,10]');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashed overrides are forward-compatible: no effect when fields absent', () => {
|
||||||
|
// The byRegexp matcher only affects series whose name ends in .min/.max.
|
||||||
|
// When the node doesn't emit those fields, the override has no effect on
|
||||||
|
// the rendered panel — series simply don't appear. Verified by the
|
||||||
|
// matcher pattern being a strict regex.
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const dash = api.loadTemplate('machine');
|
||||||
|
const ts = dash.panels.filter((p) => p.type === 'timeseries')[0];
|
||||||
|
const minOv = ts.fieldConfig.overrides.find(
|
||||||
|
(o) => o.matcher?.id === 'byRegexp' && /\.min\$/.test(o.matcher.options || '')
|
||||||
|
);
|
||||||
|
assert.match(minOv.matcher.options, /\$$/, 'matcher anchored to end of name');
|
||||||
|
});
|
||||||
102
test/basic/slice39-no-duplication.basic.test.js
Normal file
102
test/basic/slice39-no-duplication.basic.test.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
|
||||||
|
function makeChild(id, softwareType) {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
general: { id, name: id },
|
||||||
|
functionality: { softwareType, positionVsParent: 'downstream' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRoot(softwareType, children) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const c of children) {
|
||||||
|
map.set(c.config.general.id, {
|
||||||
|
child: c,
|
||||||
|
softwareType: c.config.functionality.softwareType,
|
||||||
|
position: 'downstream',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
general: { id: 'root-1', name: 'PS-North' },
|
||||||
|
functionality: { softwareType, positionVsParent: 'atequipment' },
|
||||||
|
},
|
||||||
|
childRegistrationUtils: { registeredChildren: map },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('pumpingStation template has emittedFields on every non-row panel', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const dash = api.loadTemplate('pumpingStation');
|
||||||
|
const annotated = dash.panels.filter((p) => p.type !== 'row' && p?.meta?.emittedFields);
|
||||||
|
const nonRowPanels = dash.panels.filter((p) => p.type !== 'row');
|
||||||
|
assert.equal(annotated.length, nonRowPanels.length,
|
||||||
|
`expected all ${nonRowPanels.length} non-row panels annotated, got ${annotated.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('child-covered fields remove duplicate parent panels', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
|
||||||
|
// Parent + 1 child with a fake template that emits 'level' (matches one of
|
||||||
|
// the pumpingStation parent's panels). The parent's "Level" panel should
|
||||||
|
// be removed when the child covers it.
|
||||||
|
const child1 = makeChild('child-1', 'measurement');
|
||||||
|
const root = makeRoot('pumpingStation', [child1]);
|
||||||
|
|
||||||
|
// Pre-count parent panels with the 'level' emitted field.
|
||||||
|
const parentTemplate = api.loadTemplate('pumpingStation');
|
||||||
|
const parentLevelPanels = parentTemplate.panels.filter(
|
||||||
|
(p) => p?.meta?.emittedFields?.includes('level')
|
||||||
|
);
|
||||||
|
assert.ok(parentLevelPanels.length > 0, 'parent has level panels in template');
|
||||||
|
|
||||||
|
// Monkey-patch the child's dashboard to claim it covers 'level'.
|
||||||
|
const origLoad = api.loadTemplate.bind(api);
|
||||||
|
api.loadTemplate = function (type) {
|
||||||
|
const dash = origLoad(type);
|
||||||
|
if (type === 'measurement' && dash) {
|
||||||
|
// Inject emittedFields = ['level'] on first non-row panel.
|
||||||
|
const firstPanel = dash.panels.find((p) => p.type !== 'row');
|
||||||
|
if (firstPanel) (firstPanel.meta ||= {}).emittedFields = ['level'];
|
||||||
|
}
|
||||||
|
return dash;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = api.generateDashboardsForGraph(root);
|
||||||
|
const rootResult = result[0];
|
||||||
|
const rootLevelPanels = rootResult.dashboard.panels.filter(
|
||||||
|
(p) => p?.meta?.emittedFields?.includes('level')
|
||||||
|
);
|
||||||
|
assert.equal(rootLevelPanels.length, 0,
|
||||||
|
'level panel(s) should be removed from parent when child covers them');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parent panels are kept when no child covers their fields', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const child1 = makeChild('child-1', 'measurement'); // measurement.json has no emittedFields
|
||||||
|
const root = makeRoot('pumpingStation', [child1]);
|
||||||
|
const result = api.generateDashboardsForGraph(root);
|
||||||
|
const rootResult = result[0];
|
||||||
|
const beforeTemplate = api.loadTemplate('pumpingStation');
|
||||||
|
const beforeNonRow = beforeTemplate.panels.filter((p) => p.type !== 'row').length;
|
||||||
|
const afterNonRow = rootResult.dashboard.panels.filter((p) => p.type !== 'row').length;
|
||||||
|
assert.equal(afterNonRow, beforeNonRow,
|
||||||
|
'no panels should be removed when no child declares overlapping fields');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row panels are never removed (structural)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const child1 = makeChild('child-1', 'measurement');
|
||||||
|
const root = makeRoot('pumpingStation', [child1]);
|
||||||
|
const result = api.generateDashboardsForGraph(root);
|
||||||
|
const rootRows = result[0].dashboard.panels.filter((p) => p.type === 'row');
|
||||||
|
const templateRows = api.loadTemplate('pumpingStation').panels.filter((p) => p.type === 'row');
|
||||||
|
assert.equal(rootRows.length, templateRows.length, 'all row panels preserved');
|
||||||
|
});
|
||||||
69
test/basic/slice40-mgc-template.basic.test.js
Normal file
69
test/basic/slice40-mgc-template.basic.test.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
|
||||||
|
test('MGC template panels are all group-level (no per-pump fields)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const dash = api.loadTemplate('machineGroup');
|
||||||
|
const PER_PUMP = new Set(['ctrl', 'state', 'runtime', 'pressure.upstream', 'pressure.downstream', 'temperature']);
|
||||||
|
for (const panel of dash.panels || []) {
|
||||||
|
if (panel.type === 'row') continue;
|
||||||
|
const fields = panel?.meta?.emittedFields || [];
|
||||||
|
for (const f of fields) {
|
||||||
|
assert.ok(!PER_PUMP.has(f),
|
||||||
|
`MGC panel "${panel.title}" emits ${f}, which belongs to rotatingMachine (per-pump). Move to children.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MGC group panels are annotated (mode, scaling, abs/rel peak, totals)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const dash = api.loadTemplate('machineGroup');
|
||||||
|
const non = dash.panels.filter((p) => p.type !== 'row');
|
||||||
|
const annotated = non.filter((p) => p?.meta?.emittedFields);
|
||||||
|
assert.equal(annotated.length, non.length, 'every non-row MGC panel annotated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MGC timeseries panels carry dashed-bounds overrides for .min/.max', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const dash = api.loadTemplate('machineGroup');
|
||||||
|
const ts = dash.panels.filter((p) => p.type === 'timeseries');
|
||||||
|
for (const panel of ts) {
|
||||||
|
const ov = panel?.fieldConfig?.overrides || [];
|
||||||
|
const hasMin = ov.some((o) => o.matcher?.id === 'byRegexp' && /\.min\$/.test(o.matcher?.options || ''));
|
||||||
|
const hasMax = ov.some((o) => o.matcher?.id === 'byRegexp' && /\.max\$/.test(o.matcher?.options || ''));
|
||||||
|
assert.ok(hasMin && hasMax, `MGC ts panel "${panel.title}" missing .min/.max dashed override`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MGC composer dedups parent panels covered by pump children', () => {
|
||||||
|
// If a rotatingMachine child claims to emit `flow.total` (it shouldn't, but
|
||||||
|
// suppose), the parent MGC's "Total Flow" panel would be removed. Verify
|
||||||
|
// the composer applies the same dedup rule to MGC parents.
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
function makeChildSrc(id) {
|
||||||
|
return { config: { general: { id }, functionality: { softwareType: 'machine', positionVsParent: 'downstream' } } };
|
||||||
|
}
|
||||||
|
const child = makeChildSrc('pump-1');
|
||||||
|
const root = {
|
||||||
|
config: { general: { id: 'mgc-1', name: 'MGC' }, functionality: { softwareType: 'machineGroupControl', positionVsParent: 'atequipment' } },
|
||||||
|
childRegistrationUtils: { registeredChildren: new Map([['pump-1', { child, position: 'downstream', softwareType: 'machine' }]]) },
|
||||||
|
};
|
||||||
|
const origLoad = api.loadTemplate.bind(api);
|
||||||
|
api.loadTemplate = function (t) {
|
||||||
|
const dash = origLoad(t);
|
||||||
|
if (t === 'machine') {
|
||||||
|
// Make the pump's template falsely claim it emits flow.total/flow.group
|
||||||
|
const firstPanel = dash.panels.find((p) => p.type !== 'row');
|
||||||
|
if (firstPanel) (firstPanel.meta ||= {}).emittedFields = ['flow.total', 'flow.group'];
|
||||||
|
}
|
||||||
|
return dash;
|
||||||
|
};
|
||||||
|
const results = api.generateDashboardsForGraph(root);
|
||||||
|
const mgcDash = results[0].dashboard;
|
||||||
|
const totalFlowPanel = mgcDash.panels.find((p) => p.title === 'Total Flow');
|
||||||
|
assert.ok(!totalFlowPanel, 'MGC Total Flow panel should be removed when child claims flow.total/flow.group');
|
||||||
|
});
|
||||||
75
test/basic/slice41-manual-regen.basic.test.js
Normal file
75
test/basic/slice41-manual-regen.basic.test.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
const handlers = require('../../src/commands/handlers.js');
|
||||||
|
|
||||||
|
function makeCtx(sends, nodeId = 'dApi-1') {
|
||||||
|
return {
|
||||||
|
node: { id: nodeId },
|
||||||
|
RED: { nodes: { getNode: () => null } },
|
||||||
|
send: (m) => sends.push(m),
|
||||||
|
logger: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChildPayload(id, softwareType = 'measurement') {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
general: { id, name: id },
|
||||||
|
functionality: { softwareType, positionVsParent: 'downstream' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('recordChild caches child source by id; subsequent ones replace by id', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
api.recordChild(makeChildPayload('a'));
|
||||||
|
api.recordChild(makeChildPayload('b'));
|
||||||
|
api.recordChild(makeChildPayload('a')); // replace
|
||||||
|
assert.equal(api.cachedChildSources().length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regenerate-dashboard with no cached children is a no-op (no msgs emitted)', async () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const sends = [];
|
||||||
|
await handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, makeCtx(sends));
|
||||||
|
assert.equal(sends.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regenerate-dashboard re-emits for each cached child, bypassing diff', async () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
// Pre-populate cache as if two children had registered.
|
||||||
|
api.recordChild(makeChildPayload('m-1'));
|
||||||
|
api.recordChild(makeChildPayload('m-2'));
|
||||||
|
|
||||||
|
// Set a diff that says nothing changed — registerChild would skip, but
|
||||||
|
// regenerateDashboard should ignore the predicate.
|
||||||
|
api.lastFlowsStartedDiff = { added: [], changed: [], removed: [], rewired: [] };
|
||||||
|
|
||||||
|
const sends = [];
|
||||||
|
await handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, makeCtx(sends));
|
||||||
|
// Each child yields at least one dashboard message (the root for the child's view).
|
||||||
|
assert.ok(sends.length >= 2, `expected ≥2 emitted msgs, got ${sends.length}`);
|
||||||
|
// Every emitted msg carries trigger: 'manual' in meta.
|
||||||
|
for (const m of sends) assert.equal(m.meta?.trigger, 'manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('child.register stamps trigger: child.register in emitted msg meta', async () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
api.lastFlowsStartedDiff = null; // cold-start → always regen
|
||||||
|
const sends = [];
|
||||||
|
await handlers.registerChild(api, { topic: 'child.register', payload: makeChildPayload('m-3') }, makeCtx(sends));
|
||||||
|
assert.ok(sends.length >= 1);
|
||||||
|
for (const m of sends) assert.equal(m.meta?.trigger, 'child.register');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('command registry exposes regenerate-dashboard with regen alias', () => {
|
||||||
|
const registry = require('../../src/commands/index.js');
|
||||||
|
const entry = registry.find((e) => e.topic === 'regenerate-dashboard');
|
||||||
|
assert.ok(entry, 'topic registered');
|
||||||
|
assert.deepEqual(entry.aliases, ['regen']);
|
||||||
|
assert.equal(typeof entry.handler, 'function');
|
||||||
|
});
|
||||||
146
test/basic/slice43-output-manifest.basic.test.js
Normal file
146
test/basic/slice43-output-manifest.basic.test.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Output-coverage tests per .claude/rules/output-coverage.md and
|
||||||
|
// test/_output-manifest.md. Every output is exercised in both populated
|
||||||
|
// and degraded states.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
const handlers = require('../../src/commands/handlers.js');
|
||||||
|
|
||||||
|
function makeChild(id, name = id, softwareType = 'measurement') {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
general: { id, name },
|
||||||
|
functionality: { softwareType, positionVsParent: 'downstream' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(nodeId = 'dApi-1') {
|
||||||
|
const sends = [];
|
||||||
|
const logs = [];
|
||||||
|
return {
|
||||||
|
sends,
|
||||||
|
logs,
|
||||||
|
ctx: {
|
||||||
|
node: { id: nodeId },
|
||||||
|
RED: { nodes: { getNode: () => null } },
|
||||||
|
send: (m) => sends.push(m),
|
||||||
|
logger: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Port 0 message shape: populated ────────────────────────────────────
|
||||||
|
test('Port 0 emit has all required keys when token + folderUid configured', async () => {
|
||||||
|
const api = new DashboardApi({
|
||||||
|
grafanaConnector: { protocol: 'http', host: 'grafana', port: 3000, bearerToken: 'tok', folderUid: 'rnd-folder' },
|
||||||
|
});
|
||||||
|
api.lastFlowsStartedDiff = null; // cold start
|
||||||
|
const { sends, ctx } = makeCtx();
|
||||||
|
await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-1', 'FT-001') }, ctx);
|
||||||
|
|
||||||
|
assert.ok(sends.length >= 1);
|
||||||
|
const m = sends[0];
|
||||||
|
assert.equal(m.topic, 'create');
|
||||||
|
assert.equal(m.method, 'POST');
|
||||||
|
assert.equal(m.headers['Accept'], 'application/json');
|
||||||
|
assert.equal(m.headers['Content-Type'], 'application/json');
|
||||||
|
assert.equal(m.headers.Authorization, 'Bearer tok');
|
||||||
|
assert.match(m.url, /^http:\/\/grafana:3000\/api\/dashboards\/db$/);
|
||||||
|
assert.equal(m.payload.overwrite, true);
|
||||||
|
assert.ok(m.payload.dashboard, 'dashboard JSON present');
|
||||||
|
assert.equal(m.payload.folderUid, 'rnd-folder');
|
||||||
|
// meta
|
||||||
|
assert.equal(m.meta.nodeId, 'm-1');
|
||||||
|
assert.equal(m.meta.softwareType, 'measurement');
|
||||||
|
assert.equal(typeof m.meta.uid, 'string');
|
||||||
|
assert.equal(m.meta.title, 'FT-001');
|
||||||
|
assert.equal(m.meta.trigger, 'child.register');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Port 0 degraded: token absent, folderUid absent ───────────────────
|
||||||
|
test('Port 0 emit omits Authorization header when no bearerToken configured', async () => {
|
||||||
|
const api = new DashboardApi({}); // no creds
|
||||||
|
api.lastFlowsStartedDiff = null;
|
||||||
|
const { sends, ctx } = makeCtx();
|
||||||
|
await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-2') }, ctx);
|
||||||
|
const m = sends[0];
|
||||||
|
assert.equal(m.headers.Authorization, undefined,
|
||||||
|
'Authorization should be absent (not empty string, not null)');
|
||||||
|
assert.equal(m.payload.folderUid, undefined,
|
||||||
|
'folderUid should be absent when empty');
|
||||||
|
assert.equal('folderId' in m.payload, false,
|
||||||
|
'folderId should also be absent (not 0)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Port 0 degraded: no template for softwareType ─────────────────────
|
||||||
|
test('Port 0 emits no message when child softwareType has no template', async () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
api.lastFlowsStartedDiff = null;
|
||||||
|
const { sends, ctx } = makeCtx();
|
||||||
|
// 'nonexistent' has no config/<>.json file
|
||||||
|
await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-3', 'm-3', 'nonexistent') }, ctx);
|
||||||
|
assert.equal(sends.length, 0, 'no upsert message should be emitted when template missing');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Diff-skip path: no emission, logged outcome:no-diff ───────────────
|
||||||
|
test('Diff-skip suppresses Port 0 emission AND records the skip in source.logger', async () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
// Set diff so the predicate returns false (no overlap with subtree).
|
||||||
|
api.lastFlowsStartedDiff = { added: ['unrelated'], changed: [], removed: [], rewired: [] };
|
||||||
|
// Stub logger to capture
|
||||||
|
const captured = [];
|
||||||
|
api.logger = { info: (e) => captured.push(e), debug: () => {} };
|
||||||
|
|
||||||
|
const { sends, ctx } = makeCtx('dApi-1');
|
||||||
|
await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-4') }, ctx);
|
||||||
|
|
||||||
|
assert.equal(sends.length, 0, 'no upsert emitted when subtree unchanged');
|
||||||
|
const skipLog = captured.find((e) => e.event === 'regen-skipped');
|
||||||
|
assert.ok(skipLog, 'skip log emitted');
|
||||||
|
assert.equal(skipLog.outcome, 'no-diff');
|
||||||
|
assert.equal(skipLog.trigger, 'child.register');
|
||||||
|
assert.equal(skipLog.dashboardApiId, 'dApi-1');
|
||||||
|
assert.equal(skipLog.childId, 'm-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Successful regen logs structured fields per N-4 ───────────────────
|
||||||
|
test('Successful regen logs event=regen-emitted with N-4 fields', async () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
api.lastFlowsStartedDiff = null; // cold start → always regen
|
||||||
|
const captured = [];
|
||||||
|
api.logger = { info: (e) => captured.push(e), debug: () => {} };
|
||||||
|
|
||||||
|
const { ctx } = makeCtx('dApi-1');
|
||||||
|
await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-5') }, ctx);
|
||||||
|
|
||||||
|
const emitLog = captured.find((e) => e.event === 'regen-emitted');
|
||||||
|
assert.ok(emitLog, 'regen-emitted log present');
|
||||||
|
assert.equal(emitLog.trigger, 'child.register');
|
||||||
|
assert.equal(emitLog.dashboardApiId, 'dApi-1');
|
||||||
|
assert.equal(emitLog.childId, 'm-5');
|
||||||
|
assert.equal(typeof emitLog.dashboardCount, 'number');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Manual regen logs manual-regen-requested + emits with trigger:manual ─
|
||||||
|
test('Manual regen logs manual-regen-requested and stamps trigger=manual', async () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
api.recordChild(makeChild('m-6'));
|
||||||
|
const captured = [];
|
||||||
|
api.logger = { info: (e) => captured.push(e), debug: () => {} };
|
||||||
|
|
||||||
|
const { sends, ctx } = makeCtx();
|
||||||
|
await handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, ctx);
|
||||||
|
|
||||||
|
const reqLog = captured.find((e) => e.event === 'manual-regen-requested');
|
||||||
|
assert.ok(reqLog, 'manual-regen-requested log present');
|
||||||
|
assert.equal(reqLog.cachedChildCount, 1);
|
||||||
|
|
||||||
|
if (sends.length > 0) {
|
||||||
|
assert.equal(sends[0].meta.trigger, 'manual');
|
||||||
|
}
|
||||||
|
});
|
||||||
104
test/basic/slice44-recursive-discovery.basic.test.js
Normal file
104
test/basic/slice44-recursive-discovery.basic.test.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
|
||||||
|
// Build a source node with an optional registered-child Map. `children` is an
|
||||||
|
// array of source nodes; each is wrapped in the { child, position, softwareType }
|
||||||
|
// entry shape that childRegistrationUtils.registeredChildren uses at runtime.
|
||||||
|
function makeNode(id, softwareType, children = [], positionVsParent = 'downstream') {
|
||||||
|
const map = new Map();
|
||||||
|
for (const c of children) {
|
||||||
|
map.set(c.config.general.id, {
|
||||||
|
child: c,
|
||||||
|
softwareType: c.config.functionality.softwareType,
|
||||||
|
position: c.config.functionality.positionVsParent || 'downstream',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
general: { id, name: id },
|
||||||
|
functionality: { softwareType, positionVsParent },
|
||||||
|
},
|
||||||
|
childRegistrationUtils: { registeredChildren: map },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('recurses a 3-level tree from a single wired root', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
// dashboardapi(root) -> machineGroup(child) -> machine(grandchild)
|
||||||
|
const grandchild = makeNode('rm-1', 'machine');
|
||||||
|
const child = makeNode('mgc-1', 'machineGroupControl', [grandchild]);
|
||||||
|
const root = makeNode('ps-1', 'pumpingStation', [child], 'atequipment');
|
||||||
|
|
||||||
|
const dashboards = api.generateDashboardsForGraph(root);
|
||||||
|
const ids = dashboards.map((d) => d.nodeId);
|
||||||
|
|
||||||
|
assert.deepEqual(ids, ['ps-1', 'mgc-1', 'rm-1'], 'pre-order: root, child, grandchild');
|
||||||
|
assert.equal(dashboards[0].nodeId, 'ps-1', 'root composed first');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each parent links only to its own direct children (per-level links)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const grandchild = makeNode('rm-1', 'machine');
|
||||||
|
const child = makeNode('mgc-1', 'machineGroupControl', [grandchild]);
|
||||||
|
const root = makeNode('ps-1', 'pumpingStation', [child], 'atequipment');
|
||||||
|
|
||||||
|
const dashboards = api.generateDashboardsForGraph(root);
|
||||||
|
const byId = Object.fromEntries(dashboards.map((d) => [d.nodeId, d.dashboard]));
|
||||||
|
|
||||||
|
assert.equal(byId['ps-1'].links.length, 1, 'root links to its one direct child');
|
||||||
|
assert.equal(byId['ps-1'].links[0].title, 'mgc-1');
|
||||||
|
assert.equal(byId['mgc-1'].links.length, 1, 'child links to its one grandchild');
|
||||||
|
assert.equal(byId['mgc-1'].links[0].title, 'rm-1');
|
||||||
|
assert.ok(!byId['rm-1'].links || byId['rm-1'].links.length === 0, 'leaf has no child links');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cycle protection: a node reachable twice is composed once', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const a = makeNode('a', 'pumpingStation', [], 'atequipment');
|
||||||
|
const b = makeNode('b', 'machineGroupControl');
|
||||||
|
// wire a -> b and b -> a (cycle)
|
||||||
|
a.childRegistrationUtils.registeredChildren.set('b', { child: b, softwareType: 'machineGroupControl', position: 'downstream' });
|
||||||
|
b.childRegistrationUtils.registeredChildren.set('a', { child: a, softwareType: 'pumpingStation', position: 'downstream' });
|
||||||
|
|
||||||
|
const dashboards = api.generateDashboardsForGraph(a);
|
||||||
|
const ids = dashboards.map((d) => d.nodeId).sort();
|
||||||
|
assert.deepEqual(ids, ['a', 'b'], 'each node composed exactly once despite the cycle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diamond topology: shared descendant composed once', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const shared = makeNode('shared', 'machine');
|
||||||
|
const left = makeNode('left', 'machineGroupControl', [shared]);
|
||||||
|
const right = makeNode('right', 'machineGroupControl', [shared]);
|
||||||
|
const root = makeNode('root', 'pumpingStation', [left, right], 'atequipment');
|
||||||
|
|
||||||
|
const dashboards = api.generateDashboardsForGraph(root);
|
||||||
|
const sharedCount = dashboards.filter((d) => d.nodeId === 'shared').length;
|
||||||
|
assert.equal(sharedCount, 1, 'shared grandchild gets a single dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtreeIdsFor recurses the full subtree (great-grandchildren included)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const ggc = makeNode('ggc-1', 'measurement');
|
||||||
|
const gc = makeNode('gc-1', 'machine', [ggc]);
|
||||||
|
const child = makeNode('child-1', 'machineGroupControl', [gc]);
|
||||||
|
|
||||||
|
const ids = api.subtreeIdsFor('dApi-1', child);
|
||||||
|
assert.ok(ids.has('dApi-1') && ids.has('child-1') && ids.has('gc-1') && ids.has('ggc-1'));
|
||||||
|
assert.equal(ids.size, 4, 'dashboardAPI + child + grandchild + great-grandchild');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includeChildren:false composes only the root (no recursion)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const grandchild = makeNode('rm-1', 'machine');
|
||||||
|
const child = makeNode('mgc-1', 'machineGroupControl', [grandchild]);
|
||||||
|
const root = makeNode('ps-1', 'pumpingStation', [child], 'atequipment');
|
||||||
|
|
||||||
|
const dashboards = api.generateDashboardsForGraph(root, { includeChildren: false });
|
||||||
|
assert.equal(dashboards.length, 1);
|
||||||
|
assert.equal(dashboards[0].nodeId, 'ps-1');
|
||||||
|
});
|
||||||
50
test/basic/slice45-template-aliases.basic.test.js
Normal file
50
test/basic/slice45-template-aliases.basic.test.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
|
||||||
|
// softwareType (as reported at runtime, lowercased) -> the template that must resolve.
|
||||||
|
const CASES = [
|
||||||
|
['rotatingmachine', 'machine.json'],
|
||||||
|
['machinegroupcontrol', 'machineGroup.json'],
|
||||||
|
['pumpingstation', 'pumpingStation.json'],
|
||||||
|
['valvegroupcontrol', 'valveGroupControl.json'],
|
||||||
|
['diffuser', 'aeration.json'],
|
||||||
|
['measurement', 'measurement.json'],
|
||||||
|
['reactor', 'reactor.json'],
|
||||||
|
['settler', 'settler.json'],
|
||||||
|
['valve', 'valve.json'],
|
||||||
|
['monster', 'monster.json'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [softwareType, file] of CASES) {
|
||||||
|
test(`softwareType '${softwareType}' resolves to ${file}`, () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const resolved = api._templateFileForSoftwareType(softwareType);
|
||||||
|
assert.ok(resolved, `expected a template path for ${softwareType}`);
|
||||||
|
assert.ok(resolved.endsWith(file), `expected ${file}, got ${resolved}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('resolution is case-insensitive (camelCase softwareType still resolves)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
assert.ok(api._templateFileForSoftwareType('rotatingMachine').endsWith('machine.json'));
|
||||||
|
assert.ok(api._templateFileForSoftwareType('machineGroupControl').endsWith('machineGroup.json'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotatingmachine now builds a dashboard (was: no template found)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const built = api.buildDashboard({
|
||||||
|
nodeConfig: { general: { id: 'rm-1', name: 'Pump A' }, functionality: { softwareType: 'rotatingmachine' } },
|
||||||
|
positionVsParent: 'downstream',
|
||||||
|
});
|
||||||
|
assert.ok(built, 'expected a built dashboard, not null');
|
||||||
|
assert.equal(built.softwareType, 'rotatingmachine');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown softwareType still returns null (no template)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
assert.equal(api._templateFileForSoftwareType('totally-unknown-type'), null);
|
||||||
|
});
|
||||||
62
test/basic/slice46-measurement-name-parity.basic.test.js
Normal file
62
test/basic/slice46-measurement-name-parity.basic.test.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// The dashboard's `_measurement` templating var MUST equal the InfluxDB
|
||||||
|
// measurement name that outputUtils.formatMsg writes telemetry under, or every
|
||||||
|
// panel queries a non-existent series and renders blank.
|
||||||
|
//
|
||||||
|
// outputUtils convention (generalFunctions/src/helper/outputUtils.js):
|
||||||
|
// measurement = config.general.name || `${softwareType}_${config.general.id}`
|
||||||
|
//
|
||||||
|
// buildDashboard must mirror it exactly.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass');
|
||||||
|
|
||||||
|
function makeApi() {
|
||||||
|
return new DashboardApi({
|
||||||
|
general: { name: 'dapi', logging: { enabled: false, logLevel: 'error' } },
|
||||||
|
grafanaConnector: { protocol: 'http', host: 'localhost', port: 3000, bearerToken: '' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function measurementVar(dash) {
|
||||||
|
return dash.dashboard.templating.list.find((v) => v.name === 'measurement').current.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('measurement var uses general.name when set (matches outputUtils)', () => {
|
||||||
|
const api = makeApi();
|
||||||
|
const dash = api.buildDashboard({
|
||||||
|
nodeConfig: {
|
||||||
|
general: { id: '248ba213d44df5b9', name: 'pumpingStation' },
|
||||||
|
functionality: { softwareType: 'pumpingstation' },
|
||||||
|
},
|
||||||
|
positionVsParent: 'atequipment',
|
||||||
|
});
|
||||||
|
assert.equal(dash.measurementName, 'pumpingStation');
|
||||||
|
assert.equal(measurementVar(dash), 'pumpingStation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('measurement var falls back to <softwareType>_<id> when name is empty', () => {
|
||||||
|
const api = makeApi();
|
||||||
|
const dash = api.buildDashboard({
|
||||||
|
nodeConfig: {
|
||||||
|
general: { id: '693ebd559017d39f', name: '' },
|
||||||
|
functionality: { softwareType: 'rotatingmachine' },
|
||||||
|
},
|
||||||
|
positionVsParent: 'atequipment',
|
||||||
|
});
|
||||||
|
assert.equal(dash.measurementName, 'rotatingmachine_693ebd559017d39f');
|
||||||
|
assert.equal(measurementVar(dash), 'rotatingmachine_693ebd559017d39f');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fallback id segment is the node id, not the title', () => {
|
||||||
|
const api = makeApi();
|
||||||
|
const dash = api.buildDashboard({
|
||||||
|
nodeConfig: {
|
||||||
|
general: { id: 'abc123' },
|
||||||
|
functionality: { softwareType: 'measurement' },
|
||||||
|
},
|
||||||
|
positionVsParent: 'upstream',
|
||||||
|
});
|
||||||
|
assert.equal(dash.measurementName, 'measurement_abc123');
|
||||||
|
});
|
||||||
156
test/basic/slice47-mgc-pump-panels.basic.test.js
Normal file
156
test/basic/slice47-mgc-pump-panels.basic.test.js
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
|
||||||
|
// Build an MGC root with N rotatingMachine children, compose the graph, and
|
||||||
|
// return the MGC dashboard (results[0]).
|
||||||
|
function composeMgcWith(pumpDefs) {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const entries = pumpDefs.map((p) => [p.id, {
|
||||||
|
child: { config: { general: { id: p.id, name: p.name }, functionality: { softwareType: p.softwareType || 'machine', positionVsParent: 'downstream' } } },
|
||||||
|
position: 'downstream',
|
||||||
|
}]);
|
||||||
|
const root = {
|
||||||
|
config: { general: { id: 'mgc-1', name: 'MGC' }, functionality: { softwareType: 'machineGroupControl', positionVsParent: 'atequipment' } },
|
||||||
|
childRegistrationUtils: { registeredChildren: new Map(entries) },
|
||||||
|
};
|
||||||
|
return { api, dash: api.generateDashboardsForGraph(root)[0].dashboard };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PUMPS = [
|
||||||
|
{ id: 'pump-a', name: 'Pump A' },
|
||||||
|
{ id: 'pump-b', name: 'Pump B' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('MGC dashboard gains the three pump fan-out panels', () => {
|
||||||
|
const { dash } = composeMgcWith(PUMPS);
|
||||||
|
const titles = dash.panels.filter((p) => p.type === 'timeseries').map((p) => p.title);
|
||||||
|
assert.ok(titles.includes('Pump % Control'), 'missing % control panel');
|
||||||
|
assert.ok(titles.includes('Pump Predicted Flow vs Demand'), 'missing flow panel');
|
||||||
|
assert.ok(titles.includes('Pump Predicted Power'), 'missing power panel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('static group-total panels are replaced by the richer fan-out panels', () => {
|
||||||
|
const { dash } = composeMgcWith(PUMPS);
|
||||||
|
const titles = dash.panels.map((p) => p.title);
|
||||||
|
assert.ok(!titles.includes('Total Flow'), 'static Total Flow should be removed');
|
||||||
|
assert.ok(!titles.includes('Total Power'), 'static Total Power should be removed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('% control query targets every pump measurement; ctrl is already percent (no scaling)', () => {
|
||||||
|
const { dash } = composeMgcWith(PUMPS);
|
||||||
|
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
|
||||||
|
const q = panel.targets[0].query;
|
||||||
|
assert.match(q, /r\._measurement == "Pump A"/);
|
||||||
|
assert.match(q, /r\._measurement == "Pump B"/);
|
||||||
|
assert.match(q, /r\._field == "ctrl"/);
|
||||||
|
assert.ok(!/_value \* 100/.test(q), 'ctrl is 0..100 already — must NOT be ×100 scaled');
|
||||||
|
assert.equal(panel.fieldConfig.defaults.unit, 'percent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('% control plots both realized position and commanded setpoint per pump', () => {
|
||||||
|
const { dash } = composeMgcWith(PUMPS);
|
||||||
|
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
|
||||||
|
const realized = panel.targets.find((t) => /r\._field == "ctrl"/.test(t.query));
|
||||||
|
const setpoint = panel.targets.find((t) => /ctrl\\\.predicted\\\.atequipment/.test(t.query));
|
||||||
|
assert.ok(realized, 'missing realized-position (ctrl) series');
|
||||||
|
assert.ok(setpoint, 'missing commanded-setpoint (ctrl.predicted.atequipment) series');
|
||||||
|
// childId varies per pump → setpoint must be a regex (=~) prefix match.
|
||||||
|
assert.match(setpoint.query, /r\._field =~ \/\^ctrl\\\.predicted\\\.atequipment\\\.\//);
|
||||||
|
assert.ok(!/\.default/.test(setpoint.query), 'must not hardcode childId .default');
|
||||||
|
// Legend disambiguation: each series suffixes its _measurement.
|
||||||
|
assert.match(realized.query, /\(realized\)/);
|
||||||
|
assert.match(setpoint.query, /\(setpoint\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setpoint series is drawn dashed to distinguish it from realized', () => {
|
||||||
|
const { dash } = composeMgcWith(PUMPS);
|
||||||
|
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
|
||||||
|
const ov = panel.fieldConfig.overrides.find((o) => /setpoint/.test(o.matcher.options));
|
||||||
|
assert.ok(ov, 'missing dashed override for setpoint series');
|
||||||
|
assert.equal(ov.matcher.id, 'byRegexp');
|
||||||
|
const lineStyle = ov.properties.find((p) => p.id === 'custom.lineStyle')?.value;
|
||||||
|
assert.equal(lineStyle?.fill, 'dash', 'setpoint must be dashed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('per-pump flow/power match the position prefix (childId varies per pump)', () => {
|
||||||
|
const { dash } = composeMgcWith(PUMPS);
|
||||||
|
const flowQ = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand').targets[0].query;
|
||||||
|
const powerQ = dash.panels.find((p) => p.title === 'Pump Predicted Power').targets[0].query;
|
||||||
|
// Regex field match (=~), not an exact `.default` key, so it catches
|
||||||
|
// `flow.predicted.atequipment.<pumpId>` whatever the childId is.
|
||||||
|
assert.match(flowQ, /r\._field =~ \/\^flow\\\.predicted\\\.atequipment\\\.\//);
|
||||||
|
assert.match(powerQ, /r\._field =~ \/\^power\\\.predicted\\\.atequipment\\\.\//);
|
||||||
|
assert.ok(!/\.default/.test(flowQ), 'must not hardcode childId .default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('measurement name falls back to <softwareType>_<id> when name is unset', () => {
|
||||||
|
const { dash } = composeMgcWith([{ id: 'p9', softwareType: 'rotatingmachine' }]);
|
||||||
|
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
|
||||||
|
assert.match(panel.targets[0].query, /r\._measurement == "rotatingmachine_p9"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flow panel folds in total flow, demand setpoint, demand %, and per-pump flow', () => {
|
||||||
|
const { dash } = composeMgcWith(PUMPS);
|
||||||
|
const panel = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand');
|
||||||
|
const queries = panel.targets.map((t) => t.query).join('\n');
|
||||||
|
assert.match(queries, /flow\\\.predicted\\\.atequipment/, 'per-pump flow field');
|
||||||
|
assert.match(queries, /atEquipment_predicted_flow/, 'group total flow field');
|
||||||
|
assert.match(queries, /demandFlow/, 'resolved flow setpoint field');
|
||||||
|
assert.match(queries, /demandPct/, 'demand percent field');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flow capacity envelope is drawn as dashed min/max lines', () => {
|
||||||
|
const { dash } = composeMgcWith(PUMPS);
|
||||||
|
const panel = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand');
|
||||||
|
const byName = Object.fromEntries(
|
||||||
|
panel.fieldConfig.overrides.map((o) => [o.matcher.options, o.properties]));
|
||||||
|
for (const cap of ['flowCapacityMin', 'flowCapacityMax']) {
|
||||||
|
const props = byName[cap];
|
||||||
|
assert.ok(props, `missing override for ${cap}`);
|
||||||
|
const lineStyle = props.find((p) => p.id === 'custom.lineStyle')?.value;
|
||||||
|
assert.equal(lineStyle?.fill, 'dash', `${cap} must be dashed`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('demand % is placed on a secondary (right) axis in percent', () => {
|
||||||
|
const { dash } = composeMgcWith(PUMPS);
|
||||||
|
const panel = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand');
|
||||||
|
const props = panel.fieldConfig.overrides.find((o) => o.matcher.options === 'demandPct')?.properties || [];
|
||||||
|
assert.equal(props.find((p) => p.id === 'unit')?.value, 'percent');
|
||||||
|
assert.equal(props.find((p) => p.id === 'custom.axisPlacement')?.value, 'right');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('power panel folds total power in with per-pump power', () => {
|
||||||
|
const { dash } = composeMgcWith(PUMPS);
|
||||||
|
const panel = dash.panels.find((p) => p.title === 'Pump Predicted Power');
|
||||||
|
const queries = panel.targets.map((t) => t.query).join('\n');
|
||||||
|
assert.match(queries, /power\\\.predicted\\\.atequipment/, 'per-pump power field');
|
||||||
|
assert.match(queries, /atEquipment_predicted_power/, 'group total power field');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('injected panels are exempt from the no-duplication dedup (empty emittedFields)', () => {
|
||||||
|
const { dash } = composeMgcWith(PUMPS);
|
||||||
|
const dynamic = dash.panels.filter((p) => p?.meta?.dynamic === 'mgc-pump-fanout');
|
||||||
|
assert.equal(dynamic.length, 3);
|
||||||
|
for (const p of dynamic) assert.deepEqual(p.meta.emittedFields, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a machineGroup with no pump children keeps the static template panels', () => {
|
||||||
|
const { dash } = composeMgcWith([
|
||||||
|
{ id: 'm1', name: 'Meter', softwareType: 'measurement' },
|
||||||
|
]);
|
||||||
|
const titles = dash.panels.map((p) => p.title);
|
||||||
|
assert.ok(titles.includes('Total Flow'), 'static totals must remain when no pumps');
|
||||||
|
assert.ok(!titles.includes('Pump % Control'), 'no fan-out panels without pumps');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('injected panels reuse the dashboard influxdb datasource uid', () => {
|
||||||
|
const { dash } = composeMgcWith(PUMPS);
|
||||||
|
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
|
||||||
|
assert.equal(panel.datasource.type, 'influxdb');
|
||||||
|
assert.equal(panel.datasource.uid, 'cdzg44tv250jkd');
|
||||||
|
});
|
||||||
100
test/basic/slice48-folder-resolve-by-name.basic.test.js
Normal file
100
test/basic/slice48-folder-resolve-by-name.basic.test.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
const { registerChild } = require('../../src/commands/handlers.js');
|
||||||
|
|
||||||
|
// Minimal fetch double. `routes` maps `${method} ${pathname}` to a response
|
||||||
|
// descriptor { ok, status, body }. Records every call for assertions.
|
||||||
|
function makeFetch(routes) {
|
||||||
|
const calls = [];
|
||||||
|
const fetchImpl = async (url, opts = {}) => {
|
||||||
|
const method = opts.method || 'GET';
|
||||||
|
const { pathname } = new URL(url);
|
||||||
|
calls.push({ method, pathname, body: opts.body });
|
||||||
|
const r = routes[`${method} ${pathname}`];
|
||||||
|
if (!r) return { ok: false, status: 404, json: async () => ({ message: 'not found' }) };
|
||||||
|
if (typeof r === 'function') return r();
|
||||||
|
return { ok: r.ok ?? true, status: r.status ?? 200, json: async () => r.body };
|
||||||
|
};
|
||||||
|
fetchImpl.calls = calls;
|
||||||
|
return fetchImpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function api(grafanaConnector) {
|
||||||
|
return new DashboardApi({ grafanaConnector });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('no folderTitle → returns configured folderUid without any fetch (legacy path)', async () => {
|
||||||
|
const a = api({ folderUid: 'pinned-uid' });
|
||||||
|
const fetchImpl = makeFetch({});
|
||||||
|
const uid = await a.resolveFolderUid({ fetchImpl });
|
||||||
|
assert.equal(uid, 'pinned-uid');
|
||||||
|
assert.equal(fetchImpl.calls.length, 0, 'must not call Grafana when no folderTitle is set');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('folderTitle matches an existing folder (case-insensitive) → returns its uid', async () => {
|
||||||
|
const a = api({ folderTitle: 'EVOLV' });
|
||||||
|
const fetchImpl = makeFetch({
|
||||||
|
'GET /api/folders': { body: [{ title: 'Other', uid: 'x' }, { title: 'evolv', uid: 'bfncls6af0b9cb' }] },
|
||||||
|
});
|
||||||
|
const uid = await a.resolveFolderUid({ fetchImpl });
|
||||||
|
assert.equal(uid, 'bfncls6af0b9cb');
|
||||||
|
assert.equal(fetchImpl.calls.filter((c) => c.method === 'POST').length, 0, 'must not create when found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolution is cached → second call makes no further fetch', async () => {
|
||||||
|
const a = api({ folderTitle: 'EVOLV' });
|
||||||
|
const fetchImpl = makeFetch({ 'GET /api/folders': { body: [{ title: 'EVOLV', uid: 'u1' }] } });
|
||||||
|
await a.resolveFolderUid({ fetchImpl });
|
||||||
|
await a.resolveFolderUid({ fetchImpl });
|
||||||
|
assert.equal(fetchImpl.calls.length, 1, 'second resolve should hit the cache');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('folder absent → creates it by name and returns the new uid', async () => {
|
||||||
|
const a = api({ folderTitle: 'EVOLV' });
|
||||||
|
const fetchImpl = makeFetch({
|
||||||
|
'GET /api/folders': { body: [{ title: 'Other', uid: 'x' }] },
|
||||||
|
'POST /api/folders': { status: 200, body: { uid: 'created-uid', title: 'EVOLV' } },
|
||||||
|
});
|
||||||
|
const uid = await a.resolveFolderUid({ fetchImpl });
|
||||||
|
assert.equal(uid, 'created-uid');
|
||||||
|
const post = fetchImpl.calls.find((c) => c.method === 'POST');
|
||||||
|
assert.equal(JSON.parse(post.body).title, 'EVOLV');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetch throws → falls back to configured folderUid (never worse than pinned)', async () => {
|
||||||
|
const a = api({ folderTitle: 'EVOLV', folderUid: 'fallback-uid' });
|
||||||
|
const fetchImpl = async () => { throw new Error('ECONNREFUSED'); };
|
||||||
|
const uid = await a.resolveFolderUid({ fetchImpl });
|
||||||
|
assert.equal(uid, 'fallback-uid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no fetch implementation available → falls back to configured folderUid', async () => {
|
||||||
|
const a = api({ folderTitle: 'EVOLV', folderUid: 'fallback-uid' });
|
||||||
|
// Pass an explicit non-function (not undefined, which would trigger the
|
||||||
|
// globalThis.fetch default) to exercise the "no fetch available" branch.
|
||||||
|
const uid = await a.resolveFolderUid({ fetchImpl: null });
|
||||||
|
assert.equal(uid, 'fallback-uid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emit path stamps the resolved folderUid onto every upsert payload', async () => {
|
||||||
|
const a = api({ folderTitle: 'EVOLV' });
|
||||||
|
// Force a deterministic resolution without standing up fetch.
|
||||||
|
a.resolveFolderUid = async () => 'resolved-folder-uid';
|
||||||
|
|
||||||
|
const childSource = {
|
||||||
|
config: { general: { id: 'm1', name: 'Level' }, functionality: { softwareType: 'measurement' } },
|
||||||
|
};
|
||||||
|
const sent = [];
|
||||||
|
const ctx = { node: { id: 'dapi' }, send: (m) => sent.push(m) };
|
||||||
|
await registerChild(a, { payload: childSource }, ctx);
|
||||||
|
|
||||||
|
assert.ok(sent.length >= 1, 'should emit at least one create');
|
||||||
|
for (const m of sent) {
|
||||||
|
assert.equal(m.topic, 'create');
|
||||||
|
assert.equal(m.payload.folderUid, 'resolved-folder-uid');
|
||||||
|
}
|
||||||
|
});
|
||||||
174
test/basic/slice49-datasource-resolve.basic.test.js
Normal file
174
test/basic/slice49-datasource-resolve.basic.test.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const DashboardApi = require('../../src/specificClass.js');
|
||||||
|
const { registerChild } = require('../../src/commands/handlers.js');
|
||||||
|
|
||||||
|
function makeFetch(routes) {
|
||||||
|
const calls = [];
|
||||||
|
const fetchImpl = async (url, opts = {}) => {
|
||||||
|
const method = opts.method || 'GET';
|
||||||
|
const { pathname } = new URL(url);
|
||||||
|
calls.push({ method, pathname, body: opts.body });
|
||||||
|
const r = routes[`${method} ${pathname}`];
|
||||||
|
if (!r) return { ok: false, status: 404, json: async () => ({ message: 'not found' }) };
|
||||||
|
if (typeof r === 'function') return r();
|
||||||
|
return { ok: r.ok ?? true, status: r.status ?? 200, json: async () => r.body };
|
||||||
|
};
|
||||||
|
fetchImpl.calls = calls;
|
||||||
|
return fetchImpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function api(grafanaConnector = {}) {
|
||||||
|
return new DashboardApi({ grafanaConnector });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('resolveDatasourceUid returns the first influxdb datasource uid', async () => {
|
||||||
|
const a = api();
|
||||||
|
const fetchImpl = makeFetch({
|
||||||
|
'GET /api/datasources': {
|
||||||
|
body: [
|
||||||
|
{ type: 'prometheus', uid: 'p1' },
|
||||||
|
{ type: 'influxdb', uid: 'dfmpjg9jjvym8b', name: 'influxdb' },
|
||||||
|
{ type: 'influxdb', uid: 'second-one' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const uid = await a.resolveDatasourceUid({ fetchImpl });
|
||||||
|
assert.equal(uid, 'dfmpjg9jjvym8b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDatasourceUid is cached → second call makes no further fetch', async () => {
|
||||||
|
const a = api();
|
||||||
|
const fetchImpl = makeFetch({
|
||||||
|
'GET /api/datasources': { body: [{ type: 'influxdb', uid: 'u1' }] },
|
||||||
|
});
|
||||||
|
await a.resolveDatasourceUid({ fetchImpl });
|
||||||
|
await a.resolveDatasourceUid({ fetchImpl });
|
||||||
|
assert.equal(fetchImpl.calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDatasourceUid returns empty string when no influxdb datasource exists', async () => {
|
||||||
|
const a = api();
|
||||||
|
const fetchImpl = makeFetch({
|
||||||
|
'GET /api/datasources': { body: [{ type: 'prometheus', uid: 'p1' }] },
|
||||||
|
});
|
||||||
|
const uid = await a.resolveDatasourceUid({ fetchImpl });
|
||||||
|
assert.equal(uid, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDatasourceUid: fetch throws → returns empty string (template uid preserved)', async () => {
|
||||||
|
const a = api();
|
||||||
|
const fetchImpl = async () => { throw new Error('ECONNREFUSED'); };
|
||||||
|
const uid = await a.resolveDatasourceUid({ fetchImpl });
|
||||||
|
assert.equal(uid, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDatasourceUid: no fetch available → returns empty string', async () => {
|
||||||
|
const a = api();
|
||||||
|
const uid = await a.resolveDatasourceUid({ fetchImpl: null });
|
||||||
|
assert.equal(uid, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rewriteDatasourceUid: rewrites panel.datasource.uid for influxdb only', () => {
|
||||||
|
const a = api();
|
||||||
|
const dashboard = {
|
||||||
|
panels: [
|
||||||
|
{ datasource: { type: 'influxdb', uid: 'OLD' } },
|
||||||
|
{ datasource: { type: 'grafana', uid: '-- Grafana --' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
a.rewriteDatasourceUid(dashboard, 'NEW');
|
||||||
|
assert.equal(dashboard.panels[0].datasource.uid, 'NEW');
|
||||||
|
assert.equal(dashboard.panels[1].datasource.uid, '-- Grafana --');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rewriteDatasourceUid: rewrites panel.targets[].datasource.uid', () => {
|
||||||
|
const a = api();
|
||||||
|
const dashboard = {
|
||||||
|
panels: [
|
||||||
|
{
|
||||||
|
datasource: { type: 'influxdb', uid: 'OLD' },
|
||||||
|
targets: [
|
||||||
|
{ datasource: { type: 'influxdb', uid: 'OLD' }, query: 'a' },
|
||||||
|
{ datasource: { type: 'influxdb', uid: 'OLD' }, query: 'b' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
a.rewriteDatasourceUid(dashboard, 'NEW');
|
||||||
|
for (const t of dashboard.panels[0].targets) assert.equal(t.datasource.uid, 'NEW');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rewriteDatasourceUid: descends into nested row panels', () => {
|
||||||
|
const a = api();
|
||||||
|
const dashboard = {
|
||||||
|
panels: [
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
panels: [
|
||||||
|
{ datasource: { type: 'influxdb', uid: 'OLD' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
a.rewriteDatasourceUid(dashboard, 'NEW');
|
||||||
|
assert.equal(dashboard.panels[0].panels[0].datasource.uid, 'NEW');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rewriteDatasourceUid: rewrites templating.list[] influxdb variables', () => {
|
||||||
|
const a = api();
|
||||||
|
const dashboard = {
|
||||||
|
panels: [],
|
||||||
|
templating: {
|
||||||
|
list: [
|
||||||
|
{ type: 'query', datasource: { type: 'influxdb', uid: 'OLD' } },
|
||||||
|
{ type: 'constant', datasource: { type: 'prometheus', uid: 'OLD' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
a.rewriteDatasourceUid(dashboard, 'NEW');
|
||||||
|
assert.equal(dashboard.templating.list[0].datasource.uid, 'NEW');
|
||||||
|
assert.equal(dashboard.templating.list[1].datasource.uid, 'OLD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rewriteDatasourceUid: leaves template-variable references alone (${datasource})', () => {
|
||||||
|
const a = api();
|
||||||
|
const dashboard = {
|
||||||
|
panels: [{ datasource: { type: 'influxdb', uid: '${datasource}' } }],
|
||||||
|
};
|
||||||
|
a.rewriteDatasourceUid(dashboard, 'NEW');
|
||||||
|
assert.equal(dashboard.panels[0].datasource.uid, '${datasource}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rewriteDatasourceUid: no-op when uid is falsy (preserves template)', () => {
|
||||||
|
const a = api();
|
||||||
|
const dashboard = { panels: [{ datasource: { type: 'influxdb', uid: 'KEEP' } }] };
|
||||||
|
a.rewriteDatasourceUid(dashboard, '');
|
||||||
|
assert.equal(dashboard.panels[0].datasource.uid, 'KEEP');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emit path rewrites every upsert dashboard with the resolved datasource uid', async () => {
|
||||||
|
const a = api({ folderTitle: 'EVOLV' });
|
||||||
|
a.resolveFolderUid = async () => 'fld';
|
||||||
|
a.resolveDatasourceUid = async () => 'resolved-ds-uid';
|
||||||
|
|
||||||
|
const childSource = {
|
||||||
|
config: { general: { id: 'm1', name: 'Level' }, functionality: { softwareType: 'measurement' } },
|
||||||
|
};
|
||||||
|
const sent = [];
|
||||||
|
const ctx = { node: { id: 'dapi' }, send: (m) => sent.push(m) };
|
||||||
|
await registerChild(a, { payload: childSource }, ctx);
|
||||||
|
|
||||||
|
assert.ok(sent.length >= 1);
|
||||||
|
for (const m of sent) {
|
||||||
|
const panels = m.payload?.dashboard?.panels || [];
|
||||||
|
for (const p of panels) {
|
||||||
|
if (p?.datasource?.type === 'influxdb') {
|
||||||
|
assert.equal(p.datasource.uid, 'resolved-ds-uid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -46,7 +46,8 @@ describe('DashboardApi specificClass', () => {
|
|||||||
const measurement = templ.find((v) => v.name === 'measurement');
|
const measurement = templ.find((v) => v.name === 'measurement');
|
||||||
const bucket = templ.find((v) => v.name === 'bucket');
|
const bucket = templ.find((v) => v.name === 'bucket');
|
||||||
|
|
||||||
expect(measurement.current.value).toBe('measurement_m-1');
|
// measurement var must mirror outputUtils: general.name when set.
|
||||||
|
expect(measurement.current.value).toBe('PT-1');
|
||||||
expect(bucket.current.value).toBe('lvl3');
|
expect(bucket.current.value).toBe('lvl3');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user