Compare commits
32 Commits
main
...
8a26e17780
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a26e17780 | ||
|
|
3cd749bf37 | ||
|
|
70151e52ec | ||
|
|
b3972d4a2f | ||
|
|
3529c9f970 | ||
|
|
90536d631d | ||
|
|
c4f5b68c6a | ||
|
|
8bfc67c610 | ||
|
|
5d651b59ef | ||
|
|
5533293647 | ||
|
|
990a8c09ea | ||
| dc08c85409 | |||
| 2b745dfb51 | |||
| 3c8427ed7a | |||
| 8964b0b638 | |||
| a76f22281e | |||
| e5099de986 | |||
| 8639b02e6a | |||
| aac71eb129 | |||
| bdf87ffd67 | |||
| 7fdab73ba0 | |||
|
|
dac8576cab | ||
|
|
e04c4a1132 | ||
|
|
0b857ef444 | ||
|
|
fb5a9ebff8 | ||
|
|
a9fc51d6f0 | ||
|
|
a6f09d821d | ||
|
|
f0a7904985 | ||
|
|
7b3da23fba | ||
|
|
67a374ff4f | ||
|
|
92d7eba0fd | ||
|
|
2874608375 |
18
CLAUDE.md
18
CLAUDE.md
@@ -21,3 +21,21 @@ Key points for this node:
|
|||||||
- Stack same-level siblings vertically.
|
- Stack same-level siblings vertically.
|
||||||
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
||||||
- Wrap in a Node-RED group box coloured `none` (Utility (no S88 level)).
|
- Wrap in a Node-RED group box coloured `none` (Utility (no S88 level)).
|
||||||
|
|
||||||
|
## Folder & File Layout
|
||||||
|
|
||||||
|
Every per-node file MUST use the folder name (`dashboardAPI`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
|
||||||
|
|
||||||
|
| Path | Required name |
|
||||||
|
|---|---|
|
||||||
|
| Entry file | `dashboardAPI.js` |
|
||||||
|
| Editor HTML | `dashboardAPI.html` |
|
||||||
|
| Node adapter | `src/nodeClass.js` |
|
||||||
|
| Domain logic | `src/specificClass.js` |
|
||||||
|
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
|
||||||
|
| Tests | `test/{basic,integration,edge}/*.test.js` |
|
||||||
|
| Example flows | `examples/*.flow.json` |
|
||||||
|
|
||||||
|
> ℹ️ **Note on the Node-RED type id.** The files are now `dashboardAPI.{js,html}` (folder-name convention satisfied 2026-05-19), but the registered type id stays lowercase: `RED.nodes.registerType('dashboardapi', …)`. Every deployed flow references the type id, not the file name, so this preserves backward compatibility. Admin endpoints (`/dashboardapi/menu.js`, `/dashboardapi/configData.js`) follow the type id and are also unchanged.
|
||||||
|
|
||||||
|
When adding new files, read the rule above first to avoid drift.
|
||||||
|
|||||||
80
CONTRACT.md
Normal file
80
CONTRACT.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# dashboardAPI — Contract
|
||||||
|
|
||||||
|
dashboardAPI is an EVOLV utility node that listens for child-registration
|
||||||
|
events from other EVOLV nodes and emits Grafana dashboard upsert HTTP
|
||||||
|
requests on Port 0. It has **no domain measurements, no tick loop, and no
|
||||||
|
parent of its own** — it is a one-shot HTTP emitter. Per
|
||||||
|
OPEN_QUESTIONS.md (2026-05-10) it does NOT extend `BaseNodeAdapter` /
|
||||||
|
`BaseDomain`; it uses the shared command registry only.
|
||||||
|
|
||||||
|
## Inputs (msg.topic on Port 0)
|
||||||
|
|
||||||
|
| Canonical | Aliases (deprecated) | Payload | Effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `child.register` | `registerChild` | string (child node id) **or** `{ source: {...} }` **or** `{ config: {...} }` (optionally `msg.includeChildren: boolean`, default `true`) | Resolves the child source (`RED.nodes.getNode` → `node._flow.getNode` → inline payload), calls `source.generateDashboardsForGraph(child, { includeChildren })`, then emits one `topic: 'create'` HTTP-upsert message on Port 0 per generated dashboard. |
|
||||||
|
|
||||||
|
Aliases log a one-time deprecation warning the first time they fire.
|
||||||
|
|
||||||
|
## Outputs (msg.topic on Port 0/1/2)
|
||||||
|
|
||||||
|
- **Port 0 (process):** one message per generated dashboard, shaped for a
|
||||||
|
downstream `http request` node:
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
topic: 'create',
|
||||||
|
url: <grafanaUpsertUrl>, // e.g. http://grafana:3000/api/dashboards/db
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer …' // only when bearerToken is set
|
||||||
|
},
|
||||||
|
payload: { dashboard: {…}, folderId: 0, overwrite: true },
|
||||||
|
meta: { nodeId, softwareType, uid, title }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Re-emits the inbound `msg` fields by spread (`{...msg, ...}`) so any
|
||||||
|
caller-supplied correlation/trace fields propagate.
|
||||||
|
- **Port 1 (InfluxDB telemetry):** **not used.** dashboardAPI has no
|
||||||
|
measurements; nothing is emitted on Port 1.
|
||||||
|
- **Port 2 (registration / control plumbing):** **not used.** dashboardAPI
|
||||||
|
is a sink for `child.register`, not a source — it does not register
|
||||||
|
itself with any parent.
|
||||||
|
|
||||||
|
## Events emitted by `source.emitter`
|
||||||
|
|
||||||
|
None. The specificClass (`DashboardApi`) exposes no `EventEmitter` — it
|
||||||
|
is a passive service that responds to method calls and returns built
|
||||||
|
dashboard payloads.
|
||||||
|
|
||||||
|
## Children accepted
|
||||||
|
|
||||||
|
Any EVOLV node whose `nodeSource.config` includes
|
||||||
|
`functionality.softwareType`. The graph walk reads children via
|
||||||
|
`nodeSource.childRegistrationUtils.registeredChildren.values()`. A
|
||||||
|
dashboard template is loaded from `config/<softwareType>.json` (with
|
||||||
|
case-insensitive fallback and a `machineGroupControl → machineGroup.json`
|
||||||
|
alias); a missing template is logged at `warn` and the dashboard is
|
||||||
|
skipped.
|
||||||
|
|
||||||
|
The dashboard's templating variables `measurement` and `bucket` are
|
||||||
|
filled from the child's id and `positionVsParent` (or
|
||||||
|
`config.defaultBucket` / `config.bucketMap[position]` overrides). The
|
||||||
|
root dashboard is augmented with `links[]` entries pointing at each
|
||||||
|
direct child dashboard.
|
||||||
|
|
||||||
|
## Why no BaseNodeAdapter / BaseDomain
|
||||||
|
|
||||||
|
- No `generalFunctions/src/configs/dashboardapi.json` — `BaseDomain`'s
|
||||||
|
constructor unconditionally calls `configManager.getConfig(ctor.name)`
|
||||||
|
and would throw. The local `dependencies/dashboardapi/dashboardapiConfig.json`
|
||||||
|
is for the editor menu endpoint, not the runtime config pipeline.
|
||||||
|
- No periodic output — `BaseNodeAdapter`'s `_emitOutputs()` /
|
||||||
|
`outputUtils.formatMsg` pipeline assumes a delta-compressed Port 0/1
|
||||||
|
stream; dashboardAPI emits HTTP-shaped messages instead.
|
||||||
|
- No registration to a parent — `BaseNodeAdapter._scheduleRegistration`
|
||||||
|
would emit a spurious `child.register` of its own.
|
||||||
|
- No status badge / tick / measurements / children of its own.
|
||||||
|
|
||||||
|
dashboardAPI uses the shared `commandRegistry` (canonical topic naming +
|
||||||
|
alias-with-deprecation) and stops there.
|
||||||
@@ -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,535 @@
|
|||||||
"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, "width": 400, "height": 760 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "dark-green" } },
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"name": "Zone Spill",
|
||||||
|
"type": "rectangle",
|
||||||
|
"placement": { "top": 20, "left": 10, "width": 380, "height": {{h_spill}} },
|
||||||
|
"background": { "color": { "fixed": "rgba(229, 67, 67, 0.18)" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zone HighSafety",
|
||||||
|
"type": "rectangle",
|
||||||
|
"placement": { "top": {{y_overflow}}, "left": 10, "width": 380, "height": {{h_highSafety}} },
|
||||||
|
"background": { "color": { "fixed": "rgba(242, 165, 67, 0.16)" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zone Operating",
|
||||||
|
"type": "rectangle",
|
||||||
|
"placement": { "top": {{y_highSafety}}, "left": 10, "width": 380, "height": {{h_operating}} },
|
||||||
|
"background": { "color": { "fixed": "rgba(95, 179, 122, 0.14)" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zone Dead",
|
||||||
|
"type": "rectangle",
|
||||||
|
"placement": { "top": {{y_outflow}}, "left": 10, "width": 380, "height": {{h_dead}} },
|
||||||
|
"background": { "color": { "fixed": "rgba(128, 128, 128, 0.20)" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tank Outline",
|
||||||
|
"type": "rectangle",
|
||||||
|
"placement": { "top": 20, "left": 10, "width": 380, "height": 680 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "#8a8a8a" }, "width": 2 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Line Overflow",
|
||||||
|
"type": "rectangle",
|
||||||
|
"placement": { "top": {{y_overflow}}, "left": 10, "width": 380, "height": 1 },
|
||||||
|
"background": { "color": { "fixed": "#e54343" } },
|
||||||
|
"border": { "color": { "fixed": "#e54343" }, "width": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Line HighSafety",
|
||||||
|
"type": "rectangle",
|
||||||
|
"placement": { "top": {{y_highSafety}}, "left": 10, "width": 380, "height": 1 },
|
||||||
|
"background": { "color": { "fixed": "#f2a543" } },
|
||||||
|
"border": { "color": { "fixed": "#f2a543" }, "width": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Line Inflow",
|
||||||
|
"type": "rectangle",
|
||||||
|
"placement": { "top": {{y_inflow}}, "left": 10, "width": 380, "height": 1 },
|
||||||
|
"background": { "color": { "fixed": "#5fb37a" } },
|
||||||
|
"border": { "color": { "fixed": "#5fb37a" }, "width": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Line DryRun",
|
||||||
|
"type": "rectangle",
|
||||||
|
"placement": { "top": {{y_dryRun}}, "left": 10, "width": 380, "height": 1 },
|
||||||
|
"background": { "color": { "fixed": "#5b9bd5" } },
|
||||||
|
"border": { "color": { "fixed": "#5b9bd5" }, "width": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Line Outflow",
|
||||||
|
"type": "rectangle",
|
||||||
|
"placement": { "top": {{y_outflow}}, "left": 10, "width": 380, "height": 1 },
|
||||||
|
"background": { "color": { "fixed": "#bfbfbf" } },
|
||||||
|
"border": { "color": { "fixed": "#bfbfbf" }, "width": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Label Overflow Name",
|
||||||
|
"type": "text",
|
||||||
|
"placement": { "top": {{ty_overflow}}, "left": 180, "width": 140, "height": 16 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 11, "align": "right", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Label HighSafety Name",
|
||||||
|
"type": "text",
|
||||||
|
"placement": { "top": {{ty_highSafety}}, "left": 180, "width": 140, "height": 16 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "highSafety" }, "color": { "fixed": "#cf7e20" }, "size": 11, "align": "right", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Label Inflow Name",
|
||||||
|
"type": "text",
|
||||||
|
"placement": { "top": {{ty_inflow}}, "left": 180, "width": 140, "height": 16 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 11, "align": "right", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Label DryRun Name",
|
||||||
|
"type": "text",
|
||||||
|
"placement": { "top": {{ty_dryRun}}, "left": 180, "width": 140, "height": 16 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 11, "align": "right", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Label Outflow Name",
|
||||||
|
"type": "text",
|
||||||
|
"placement": { "top": {{ty_outflow}}, "left": 180, "width": 140, "height": 16 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 11, "align": "right", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Value Overflow",
|
||||||
|
"type": "metric-value",
|
||||||
|
"placement": { "top": {{ty_overflow}}, "left": 323, "width": 65, "height": 16 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "field", "fixed": "", "field": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 11, "align": "left", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Value HighSafety",
|
||||||
|
"type": "metric-value",
|
||||||
|
"placement": { "top": {{ty_highSafety}}, "left": 323, "width": 65, "height": 16 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "field", "fixed": "", "field": "highVolumeSafetyLevel" }, "color": { "fixed": "#cf7e20" }, "size": 11, "align": "left", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Value Inflow",
|
||||||
|
"type": "metric-value",
|
||||||
|
"placement": { "top": {{ty_inflow}}, "left": 323, "width": 65, "height": 16 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "field", "fixed": "", "field": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 11, "align": "left", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Value DryRun",
|
||||||
|
"type": "metric-value",
|
||||||
|
"placement": { "top": {{ty_dryRun}}, "left": 323, "width": 65, "height": 16 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "field", "fixed": "", "field": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 11, "align": "left", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Value Outflow",
|
||||||
|
"type": "metric-value",
|
||||||
|
"placement": { "top": {{ty_outflow}}, "left": 323, "width": 65, "height": 16 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "field", "fixed": "", "field": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 11, "align": "left", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Header Rim",
|
||||||
|
"type": "text",
|
||||||
|
"placement": { "top": 2, "left": 10, "width": 380, "height": 16 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "rim ({{heightBasin}} m)" }, "color": { "fixed": "#8a8a8a" }, "size": 10, "align": "center", "valign": "middle" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Footer Floor",
|
||||||
|
"type": "text",
|
||||||
|
"placement": { "top": 702, "left": 10, "width": 380, "height": 16 },
|
||||||
|
"background": { "color": { "fixed": "transparent" } },
|
||||||
|
"border": { "color": { "fixed": "transparent" }, "width": 0 },
|
||||||
|
"config": { "text": { "mode": "fixed", "fixed": "floor (0.00 m)" }, "color": { "fixed": "#8a8a8a" }, "size": 10, "align": "center", "valign": "middle" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"outflowLevel\" or r._field==\"inflowLevel\" or r._field==\"overflowLevel\" or r._field==\"heightBasin\" or r._field==\"dryRunLevel\" or r._field==\"highVolumeSafetyLevel\" or r._field =~ /^level\\.predicted\\.atequipment/ or r._field =~ /^volume\\.predicted\\.atequipment/ or r._field =~ /^volumePercent\\.predicted\\.atequipment/))\n |> last()\n |> map(fn: (r) => ({ r with _field: if r._field =~ /^volumePercent\\.predicted/ then \"volumePercent\" else if r._field =~ /^volume\\.predicted/ then \"volume\" else if r._field =~ /^level\\.predicted/ then \"level\" else r._field, _time: 2020-01-01T00:00:00Z }))\n |> group()\n |> keep(columns:[\"_field\",\"_value\",\"_time\"])\n |> pivot(rowKey:[\"_time\"], columnKey:[\"_field\"], valueColumn:\"_value\")",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Tank Layout",
|
||||||
|
"type": "canvas",
|
||||||
|
"meta": { "emittedFields": ["basinLayout"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "lengthm",
|
||||||
|
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 10, "w": 14, "x": 10, "y": 6 },
|
||||||
"id": 8,
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,22 @@
|
|||||||
<script>
|
<script>
|
||||||
RED.nodes.registerType('dashboardapi', {
|
RED.nodes.registerType('dashboardapi', {
|
||||||
category: 'EVOLV',
|
category: 'EVOLV',
|
||||||
color: '#4f8582',
|
color: '#7A8BA3',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: { value: '' },
|
name: { value: '' },
|
||||||
enableLog: { value: false },
|
enableLog: { value: true },
|
||||||
logLevel: { value: 'info' },
|
logLevel: { value: 'info' },
|
||||||
|
|
||||||
protocol: { value: 'http' },
|
protocol: { value: 'http' },
|
||||||
host: { value: 'localhost' },
|
host: { value: 'localhost' },
|
||||||
port: { value: 3000 },
|
port: { value: 3000 },
|
||||||
bearerToken: { value: '' },
|
folderTitle: { value: '' },
|
||||||
|
folderUid: { value: '' },
|
||||||
defaultBucket: { value: '' },
|
defaultBucket: { value: '' },
|
||||||
},
|
},
|
||||||
|
credentials: {
|
||||||
|
bearerToken: { type: 'password' },
|
||||||
|
},
|
||||||
inputs: 1,
|
inputs: 1,
|
||||||
outputs: 1,
|
outputs: 1,
|
||||||
inputLabels: ['Input'],
|
inputLabels: ['Input'],
|
||||||
@@ -44,11 +48,12 @@
|
|||||||
window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node);
|
window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
['name', 'protocol', 'host', 'port', 'bearerToken', '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 || '';
|
||||||
});
|
});
|
||||||
|
// bearerToken is handled by Node-RED's credentials system (encrypted at rest in flow_cred.json).
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -80,7 +85,17 @@
|
|||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-bearerToken"><i class="fa fa-key"></i> Bearer Token</label>
|
<label for="node-input-bearerToken"><i class="fa fa-key"></i> Bearer Token</label>
|
||||||
<input type="password" id="node-input-bearerToken" placeholder="optional" style="width:70%;" />
|
<input type="password" id="node-input-bearerToken" placeholder="encrypted at rest" style="width:70%;" />
|
||||||
|
</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">
|
||||||
|
<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 fallback — leave empty when Folder name is set" style="width:70%;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -9,6 +9,10 @@ module.exports = function (RED) {
|
|||||||
RED.nodes.registerType(nameOfNode, function (config) {
|
RED.nodes.registerType(nameOfNode, function (config) {
|
||||||
RED.nodes.createNode(this, config);
|
RED.nodes.createNode(this, config);
|
||||||
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
|
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
|
||||||
|
}, {
|
||||||
|
credentials: {
|
||||||
|
bearerToken: { type: 'password' },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const menuMgr = new MenuManager();
|
const menuMgr = new MenuManager();
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -1,10 +1,13 @@
|
|||||||
{
|
{
|
||||||
"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": {
|
||||||
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
|
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js",
|
||||||
|
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
|
||||||
|
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
|
||||||
|
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"dashboard",
|
"dashboard",
|
||||||
@@ -19,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"node-red": {
|
"node-red": {
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"dashboardapi": "dashboardapi.js"
|
"dashboardapi": "dashboardAPI.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
130
src/commands/handlers.js
Normal file
130
src/commands/handlers.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Resolve a child's source object from a registration payload.
|
||||||
|
// Payload may be: a string (node id) | { source: {...} } | { config: {...} }.
|
||||||
|
function resolveChildSource(payload, ctx) {
|
||||||
|
if (payload?.source?.config) return payload.source;
|
||||||
|
if (payload?.config) return { config: payload.config };
|
||||||
|
if (typeof payload === 'string') {
|
||||||
|
const childNode = resolveChildNode(payload, ctx);
|
||||||
|
return childNode?.source || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveChildNode(childId, ctx) {
|
||||||
|
const runtimeNode = ctx.RED?.nodes?.getNode?.(childId);
|
||||||
|
if (runtimeNode?.source?.config) return runtimeNode;
|
||||||
|
|
||||||
|
const flowNode = ctx.node?._flow?.getNode?.(childId);
|
||||||
|
if (flowNode?.source?.config) return flowNode;
|
||||||
|
|
||||||
|
return runtimeNode || flowNode || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared emit path used by both child.register (auto, deploy-driven) and
|
||||||
|
// regenerate-dashboard (manual). `trigger` distinguishes the two for logs.
|
||||||
|
async function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
|
||||||
|
const dashboards = source.generateDashboardsForGraph(childSource, {
|
||||||
|
includeChildren: Boolean(msg.includeChildren ?? true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = source.grafanaUpsertUrl();
|
||||||
|
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
|
||||||
|
const token = source.config?.grafanaConnector?.bearerToken;
|
||||||
|
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);
|
||||||
|
|
||||||
|
for (const dash of dashboards) {
|
||||||
|
ctx.send({
|
||||||
|
...msg,
|
||||||
|
topic: 'create',
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
payload: source.buildUpsertRequest({
|
||||||
|
dashboard: dash.dashboard,
|
||||||
|
folderUid: folderUid || undefined,
|
||||||
|
overwrite: true,
|
||||||
|
}),
|
||||||
|
meta: {
|
||||||
|
nodeId: dash.nodeId,
|
||||||
|
softwareType: dash.softwareType,
|
||||||
|
uid: dash.uid,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 };
|
||||||
22
src/commands/index.js
Normal file
22
src/commands/index.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// dashboardAPI command registry. Canonical names follow CONTRACTS.md §1.
|
||||||
|
// The legacy `registerChild` topic is kept as an alias of `child.register`
|
||||||
|
// (Phase 1 canonical) and logs a one-time deprecation warning on first use.
|
||||||
|
|
||||||
|
const handlers = require('./handlers');
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
topic: 'child.register',
|
||||||
|
aliases: ['registerChild'],
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
handler: handlers.registerChild,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'regenerate-dashboard',
|
||||||
|
aliases: ['regen'],
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
handler: handlers.regenerateDashboard,
|
||||||
|
},
|
||||||
|
];
|
||||||
154
src/nodeClass.js
154
src/nodeClass.js
@@ -1,23 +1,71 @@
|
|||||||
const { configManager } = require('generalFunctions');
|
'use strict';
|
||||||
|
|
||||||
|
// dashboardAPI nodeClass — passive HTTP-emitter adapter.
|
||||||
|
//
|
||||||
|
// Does NOT extend BaseNodeAdapter: dashboardAPI has no generalFunctions
|
||||||
|
// config JSON, no Port-0/1 telemetry stream, no parent registration, no
|
||||||
|
// tick or status loop. It just listens for `child.register` and emits one
|
||||||
|
// Grafana upsert HTTP request per dashboard. See OPEN_QUESTIONS.md
|
||||||
|
// (2026-05-10) for the rationale.
|
||||||
|
|
||||||
|
const { configManager, createRegistry } = require('generalFunctions');
|
||||||
const DashboardApi = require('./specificClass');
|
const DashboardApi = require('./specificClass');
|
||||||
|
const commands = require('./commands');
|
||||||
|
|
||||||
class nodeClass {
|
class nodeClass {
|
||||||
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
||||||
this.node = nodeInstance;
|
this.node = nodeInstance;
|
||||||
this.RED = RED;
|
this.RED = RED;
|
||||||
this.name = nameOfNode;
|
this.name = nameOfNode;
|
||||||
this.source = null;
|
|
||||||
this.config = null;
|
|
||||||
|
|
||||||
this._loadConfig(uiConfig);
|
this.config = this._buildConfig(uiConfig);
|
||||||
this._setupSpecificClass();
|
this.source = new DashboardApi(this.config);
|
||||||
|
this.node.source = this.source;
|
||||||
|
|
||||||
|
this._commands = createRegistry(commands, { logger: this.source?.logger });
|
||||||
|
|
||||||
this._attachInputHandler();
|
this._attachInputHandler();
|
||||||
this._attachCloseHandler();
|
this._attachCloseHandler();
|
||||||
|
this._attachLifecycleHook();
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadConfig(uiConfig) {
|
// 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) {
|
||||||
const cfgMgr = new configManager();
|
const cfgMgr = new configManager();
|
||||||
this.config = cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
|
// Credentials block (Node-RED encrypts at rest in flow_cred.json). Legacy
|
||||||
|
// installs may still carry bearerToken on uiConfig — fall back with a
|
||||||
|
// one-time deprecation warning so the user knows to re-save.
|
||||||
|
const credentialToken = this.node?.credentials?.bearerToken || '';
|
||||||
|
const legacyToken = uiConfig.bearerToken || '';
|
||||||
|
if (!credentialToken && legacyToken) {
|
||||||
|
this.RED?.log?.warn?.(
|
||||||
|
`[${this.name}] bearer token loaded from legacy plain config field. ` +
|
||||||
|
`Re-open this node in the editor and click Done to migrate to encrypted credentials.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const bearerToken = credentialToken || legacyToken;
|
||||||
|
return cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
|
||||||
functionality: {
|
functionality: {
|
||||||
softwareType: this.name.toLowerCase(),
|
softwareType: this.name.toLowerCase(),
|
||||||
role: 'auto ui generator',
|
role: 'auto ui generator',
|
||||||
@@ -26,95 +74,23 @@ class nodeClass {
|
|||||||
protocol: uiConfig.protocol || 'http',
|
protocol: uiConfig.protocol || 'http',
|
||||||
host: uiConfig.host || 'localhost',
|
host: uiConfig.host || 'localhost',
|
||||||
port: Number(uiConfig.port || 3000),
|
port: Number(uiConfig.port || 3000),
|
||||||
bearerToken: uiConfig.bearerToken || '',
|
bearerToken,
|
||||||
|
folderTitle: uiConfig.folderTitle || '',
|
||||||
|
folderUid: uiConfig.folderUid || '',
|
||||||
},
|
},
|
||||||
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',
|
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_setupSpecificClass() {
|
|
||||||
this.source = new DashboardApi(this.config);
|
|
||||||
this.node.source = this.source;
|
|
||||||
}
|
|
||||||
|
|
||||||
_resolveChildNode(childId) {
|
|
||||||
const runtimeNode = this.RED.nodes.getNode(childId);
|
|
||||||
if (runtimeNode?.source?.config) {
|
|
||||||
return runtimeNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const flowNode = this.node._flow?.getNode?.(childId);
|
|
||||||
if (flowNode?.source?.config) {
|
|
||||||
return flowNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
return runtimeNode || flowNode || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_resolveChildSource(payload) {
|
|
||||||
if (payload?.source?.config) {
|
|
||||||
return payload.source;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload?.config) {
|
|
||||||
return { config: payload.config };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof payload === 'string') {
|
|
||||||
return this._resolveChildNode(payload)?.source || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_attachInputHandler() {
|
_attachInputHandler() {
|
||||||
this.node.on('input', async (msg, send, done) => {
|
this.node.on('input', async (msg, send, done) => {
|
||||||
try {
|
try {
|
||||||
if (msg.topic !== 'registerChild') {
|
await this._commands.dispatch(msg, this.source, {
|
||||||
if (typeof done === 'function') done();
|
node: this.node,
|
||||||
return;
|
RED: this.RED,
|
||||||
}
|
send,
|
||||||
|
logger: this.source?.logger,
|
||||||
const childSource = this._resolveChildSource(msg.payload);
|
|
||||||
if (!childSource?.config) {
|
|
||||||
throw new Error('Missing or invalid child node');
|
|
||||||
}
|
|
||||||
|
|
||||||
const dashboards = this.source.generateDashboardsForGraph(childSource, {
|
|
||||||
includeChildren: Boolean(msg.includeChildren ?? true),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = this.source.grafanaUpsertUrl();
|
|
||||||
const headers = {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.config.grafanaConnector.bearerToken) {
|
|
||||||
headers.Authorization = `Bearer ${this.config.grafanaConnector.bearerToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dash of dashboards) {
|
|
||||||
send({
|
|
||||||
...msg,
|
|
||||||
topic: 'create',
|
|
||||||
url,
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
payload: this.source.buildUpsertRequest({
|
|
||||||
dashboard: dash.dashboard,
|
|
||||||
folderId: 0,
|
|
||||||
overwrite: true,
|
|
||||||
}),
|
|
||||||
meta: {
|
|
||||||
nodeId: dash.nodeId,
|
|
||||||
softwareType: dash.softwareType,
|
|
||||||
uid: dash.uid,
|
|
||||||
title: dash.title,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof done === 'function') done();
|
if (typeof done === 'function') done();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.node.status({ fill: 'red', shape: 'ring', text: 'dashboardapi error' });
|
this.node.status({ fill: 'red', shape: 'ring', text: 'dashboardapi error' });
|
||||||
@@ -126,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;
|
||||||
@@ -55,7 +91,7 @@ class DashboardApi {
|
|||||||
general: {
|
general: {
|
||||||
name: config?.general?.name || 'dashboardapi',
|
name: config?.general?.name || 'dashboardapi',
|
||||||
logging: {
|
logging: {
|
||||||
enabled: Boolean(config?.general?.logging?.enabled),
|
enabled: config?.general?.logging?.enabled ?? true,
|
||||||
logLevel: config?.general?.logging?.logLevel || 'info',
|
logLevel: config?.general?.logging?.logLevel || 'info',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -64,6 +100,13 @@ 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 || '',
|
||||||
},
|
},
|
||||||
defaultBucket: config?.defaultBucket || '',
|
defaultBucket: config?.defaultBucket || '',
|
||||||
bucketMap: config?.bucketMap || {},
|
bucketMap: config?.bucketMap || {},
|
||||||
@@ -74,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() {
|
||||||
@@ -83,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) {
|
||||||
@@ -97,29 +154,200 @@ 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);
|
||||||
|
|
||||||
|
// Canvas tank: rim at y=20px, floor at y=700px (680px tall). Must match
|
||||||
|
// hard-coded tank rectangle placement in config/pumpingStation.json
|
||||||
|
// (basin row is h:20 grid rows; canvas root frame is 400x760 px — taller
|
||||||
|
// than wide to match the card's aspect ratio so the tank fills the card
|
||||||
|
// vertically with no letterboxing).
|
||||||
|
const TANK_TOP = 20, TANK_BOT = 700, TANK_H = TANK_BOT - TANK_TOP;
|
||||||
|
const yFor = (v) => +(TANK_BOT - (v / heightBasin) * TANK_H).toFixed(2);
|
||||||
|
const tyFor = (yLine) => +(yLine - 8).toFixed(2); // centre 16px text on the line
|
||||||
|
|
||||||
|
const y_overflow = yFor(overflowLevel);
|
||||||
|
const y_highSafety = yFor(highSafetyLevel);
|
||||||
|
const y_inflow = yFor(inflowLevel);
|
||||||
|
const y_dryRun = yFor(dryRunLevel);
|
||||||
|
const y_outflow = yFor(outflowLevel);
|
||||||
|
|
||||||
|
// Label y-positions get min-gap enforcement so labels never overlap even
|
||||||
|
// when thresholds sit nearly on top of each other (e.g. dryRun=2 % means
|
||||||
|
// dryRunLevel sits right on outflowLevel; highSafety=98 % puts it under
|
||||||
|
// overflow). Lines stay at proportional y; only the label text moves.
|
||||||
|
// Two-pass (down + up) mirrors editor's basin-diagram.js placement logic.
|
||||||
|
const GAP = 20;
|
||||||
|
const labels = [
|
||||||
|
{ id: 'overflow', y: tyFor(y_overflow) },
|
||||||
|
{ id: 'highSafety', y: tyFor(y_highSafety) },
|
||||||
|
{ id: 'inflow', y: tyFor(y_inflow) },
|
||||||
|
{ id: 'dryRun', y: tyFor(y_dryRun) },
|
||||||
|
{ id: 'outflow', y: tyFor(y_outflow) },
|
||||||
|
].sort((a, b) => a.y - b.y);
|
||||||
|
for (let i = 1; i < labels.length; i++) {
|
||||||
|
if (labels[i].y < labels[i - 1].y + GAP) labels[i].y = labels[i - 1].y + GAP;
|
||||||
|
}
|
||||||
|
for (let i = labels.length - 2; i >= 0; i--) {
|
||||||
|
if (labels[i].y > labels[i + 1].y - GAP) labels[i].y = labels[i + 1].y - GAP;
|
||||||
|
}
|
||||||
|
const ty = Object.fromEntries(labels.map((l) => [l.id, +l.y.toFixed(2)]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
heightBasin: +heightBasin.toFixed(2),
|
||||||
|
outflowLevel: +outflowLevel.toFixed(3),
|
||||||
|
inflowLevel: +inflowLevel.toFixed(3),
|
||||||
|
overflowLevel: +overflowLevel.toFixed(3),
|
||||||
|
dryRunLevel: +dryRunLevel.toFixed(3),
|
||||||
|
highSafetyLevel: +highSafetyLevel.toFixed(3),
|
||||||
|
y_overflow, y_highSafety, y_inflow, y_dryRun, y_outflow,
|
||||||
|
h_spill: +(y_overflow - TANK_TOP).toFixed(2),
|
||||||
|
h_highSafety: +(y_highSafety - y_overflow).toFixed(2),
|
||||||
|
h_operating: +(y_outflow - y_highSafety).toFixed(2),
|
||||||
|
h_dead: +(TANK_BOT - y_outflow).toFixed(2),
|
||||||
|
ty_overflow: ty.overflow,
|
||||||
|
ty_highSafety: ty.highSafety,
|
||||||
|
ty_inflow: ty.inflow,
|
||||||
|
ty_dryRun: ty.dryRun,
|
||||||
|
ty_outflow: ty.outflow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect every `meta.emittedFields` declared by panels in a template.
|
||||||
|
// Used by #39's parent panel filter — a parent panel whose emittedFields
|
||||||
|
// are fully covered by its children's panels is removed.
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_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 '';
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -141,11 +369,16 @@ 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 = 0, overwrite = true }) {
|
buildUpsertRequest({ dashboard, folderId, folderUid, overwrite = true }) {
|
||||||
return { dashboard, folderId, overwrite };
|
const out = { dashboard, overwrite };
|
||||||
|
// Prefer folderUid (modern Grafana API). Fall back to folderId for older callers.
|
||||||
|
const uid = folderUid ?? this.config?.grafanaConnector?.folderUid ?? '';
|
||||||
|
if (uid) out.folderUid = uid;
|
||||||
|
else if (typeof folderId === 'number') out.folderId = folderId;
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
extractChildren(nodeSource) {
|
extractChildren(nodeSource) {
|
||||||
@@ -162,29 +395,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';
|
||||||
@@ -192,7 +521,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)}`,
|
||||||
@@ -204,7 +533,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`.
|
||||||
43
test/basic/slice34-credentials-and-folder.basic.test.js
Normal file
43
test/basic/slice34-credentials-and-folder.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('buildUpsertRequest emits folderUid when configured', () => {
|
||||||
|
const api = new DashboardApi({
|
||||||
|
grafanaConnector: { folderUid: 'rnd-folder' },
|
||||||
|
});
|
||||||
|
const req = api.buildUpsertRequest({ dashboard: { uid: 'x', title: 'X' } });
|
||||||
|
assert.equal(req.folderUid, 'rnd-folder');
|
||||||
|
assert.equal(req.overwrite, true);
|
||||||
|
assert.ok(!('folderId' in req), 'should not emit folderId when folderUid is set');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildUpsertRequest omits folderUid when empty (Grafana defaults to General)', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
const req = api.buildUpsertRequest({ dashboard: { uid: 'x' } });
|
||||||
|
assert.equal(req.folderUid, undefined);
|
||||||
|
// folderId fallback only when explicitly passed
|
||||||
|
assert.equal(req.folderId, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildUpsertRequest folderUid override at call-site wins over config', () => {
|
||||||
|
const api = new DashboardApi({ grafanaConnector: { folderUid: 'rnd-folder' } });
|
||||||
|
const req = api.buildUpsertRequest({ dashboard: { uid: 'x' }, folderUid: 'override-folder' });
|
||||||
|
assert.equal(req.folderUid, 'override-folder');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bearerToken from config flows into specificClass config', () => {
|
||||||
|
const api = new DashboardApi({
|
||||||
|
grafanaConnector: { bearerToken: 'tok-xyz', folderUid: '' },
|
||||||
|
});
|
||||||
|
assert.equal(api.config.grafanaConnector.bearerToken, 'tok-xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default config has empty bearerToken and folderUid', () => {
|
||||||
|
const api = new DashboardApi({});
|
||||||
|
assert.equal(api.config.grafanaConnector.bearerToken, '');
|
||||||
|
assert.equal(api.config.grafanaConnector.folderUid, '');
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
describe('dashboardAPI basic structure', () => {
|
const test = require('node:test');
|
||||||
it('module load smoke', () => {
|
const assert = require('node:assert/strict');
|
||||||
expect(() => {
|
|
||||||
require('../../dashboardapi.js');
|
test('dashboardAPI module load smoke', () => {
|
||||||
}).not.toThrow();
|
assert.doesNotThrow(() => {
|
||||||
|
require('../../dashboardAPI.js');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/basic.flow.json'), 'utf8'));
|
const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/basic.flow.json'), 'utf8'));
|
||||||
|
|
||||||
describe('dashboardAPI edge example structure', () => {
|
test('basic example includes node type dashboardapi', () => {
|
||||||
it('basic example includes node type dashboardapi', () => {
|
|
||||||
const count = flow.filter((n) => n && n.type === 'dashboardapi').length;
|
const count = flow.filter((n) => n && n.type === 'dashboardapi').length;
|
||||||
expect(count).toBeGreaterThanOrEqual(1);
|
assert.ok(count >= 1, `expected ≥1 dashboardapi node, got ${count}`);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
@@ -7,17 +9,15 @@ function loadJson(file) {
|
|||||||
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('dashboardAPI integration examples', () => {
|
test('examples package exists for dashboardAPI', () => {
|
||||||
it('examples package exists for dashboardAPI', () => {
|
|
||||||
for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
|
for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
|
||||||
expect(fs.existsSync(path.join(dir, file))).toBe(true);
|
assert.ok(fs.existsSync(path.join(dir, file)), `missing ${file}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('example flows are parseable arrays for dashboardAPI', () => {
|
test('example flows are parseable arrays for dashboardAPI', () => {
|
||||||
for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
|
for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
|
||||||
const parsed = loadJson(file);
|
const parsed = loadJson(file);
|
||||||
expect(Array.isArray(parsed)).toBe(true);
|
assert.ok(Array.isArray(parsed), `${file} is not an array`);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
149
wiki/Home.md
Normal file
149
wiki/Home.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# dashboardAPI
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
|
A `dashboardAPI` node converts EVOLV node topology into Grafana dashboards. On each inbound `child.register` event it resolves the child source, walks its direct children, loads per-`softwareType` Grafana JSON templates from `config/`, and emits one HTTP upsert request per dashboard on Port 0 to a downstream `http request` node. Sits adjacent to the S88 hierarchy as a passive HTTP emitter — **no measurements, no tick loop, no parent registration**.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## At a glance
|
||||||
|
|
||||||
|
| Thing | Value |
|
||||||
|
|:---|:---|
|
||||||
|
| What it represents | Utility bridge between EVOLV topology and Grafana — auto-generates dashboards from `child.register` events |
|
||||||
|
| S88 level | **Utility** — not in the S88 hierarchy; sits adjacent to it |
|
||||||
|
| Use it when | You want Grafana dashboards to materialise automatically when an EVOLV node graph is deployed |
|
||||||
|
| Don't use it for | Maintaining hand-curated Grafana dashboards (will overwrite); arbitrary Grafana API calls; tick / measurement data plumbing |
|
||||||
|
| Children it accepts | Any EVOLV node whose `nodeSource.config` carries `functionality.softwareType` |
|
||||||
|
| Parents it talks to | None — dashboardAPI is a passive sink; it does not register with a parent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it fits
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
ps[pumpingStation<br/>Process Cell]:::pc -.child.register.-> dash
|
||||||
|
mgc[machineGroupControl<br/>Unit]:::unit -.child.register.-> dash
|
||||||
|
rm[rotatingMachine<br/>Equipment]:::equip -.child.register.-> dash
|
||||||
|
meas[measurement<br/>Control Module]:::ctrl -.child.register.-> dash
|
||||||
|
dash[dashboardAPI<br/>Utility]:::neutral -->|"POST /api/dashboards/db"| http[http request<br/>node-red core]:::neutral
|
||||||
|
http --> grafana[(Grafana<br/>HTTP API)]
|
||||||
|
grafana -.renders dashboards for.-> ff[FlowFuse / Browser]
|
||||||
|
classDef pc fill:#0c99d9,color:#fff
|
||||||
|
classDef unit fill:#50a8d9,color:#000
|
||||||
|
classDef equip fill:#86bbdd,color:#000
|
||||||
|
classDef ctrl fill:#a9daee,color:#000
|
||||||
|
classDef neutral fill:#dddddd,color:#000
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashed arrows = inbound `child.register` events from any EVOLV process node. The solid arrow is the outbound HTTP upsert envelope on Port 0 — emitted **once per generated dashboard** in the walked graph. S88 colours and the utility-neutral `#dddddd` are anchored in `.claude/rules/node-red-flow-layout.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Try it — 3-minute demo
|
||||||
|
|
||||||
|
Import the basic example flow, deploy, and watch a `child.register` payload turn into a Grafana dashboard upsert request.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data @nodes/dashboardAPI/examples/basic.flow.json \
|
||||||
|
http://localhost:1880/flow
|
||||||
|
```
|
||||||
|
|
||||||
|
What to click after deploy:
|
||||||
|
|
||||||
|
1. Open the inject node (`basic trigger`) and edit the payload to a `{source: {config: {...}}}` shape — see [Reference — Examples](Reference-Examples#wiring-pattern) for the minimal inline-payload shape.
|
||||||
|
2. Fire the inject. Watch the debug pane: one `topic: 'create'` HTTP envelope appears per dashboard in the walked graph (root + direct children).
|
||||||
|
3. Wire a downstream `http request` node (method `POST`) to the dashboardAPI output to actually POST the envelope to Grafana.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **GIF needed.** Demo recording of the inject → Port-0 envelope → Grafana dashboard upsert path. Save as `wiki/_partial-gifs/dashboardAPI/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> The shipped `basic.flow.json` / `integration.flow.json` / `edge.flow.json` are stubs — the inject payloads do not yet conform to the `child.register` resolver's expected shape. They will trigger `Missing or invalid child node` errors until updated. Tracked in [Limitations — Example flow stubs](Reference-Limitations#example-flow-stubs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The one thing you'll send
|
||||||
|
|
||||||
|
| Topic | Aliases | Payload | What it does |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `child.register` | `registerChild` | `string` (child node id) **or** `{source: {...}}` **or** `{config: {...}}` (optionally `msg.includeChildren: boolean`, default `true`) | Resolves the child source (`RED.nodes.getNode` → `node._flow.getNode` → inline payload), calls `source.generateDashboardsForGraph(child, {includeChildren})`, then emits one `topic: 'create'` HTTP-upsert message on Port 0 per generated dashboard. |
|
||||||
|
|
||||||
|
That's it. There is no `set.*`, no `cmd.*`, no `query.*` — the registry has a single canonical topic (alias-with-deprecation). The legacy `registerChild` alias logs a one-time deprecation warning on first use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What you'll see come out
|
||||||
|
|
||||||
|
Sample Port 0 message after a `child.register` for a `pumpingStation` node with two direct children:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"topic": "create",
|
||||||
|
"url": "http://grafana:3000/api/dashboards/db",
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer eyJ..."
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"dashboard": { "uid": "a1b2c3d4e5f6", "title": "Pumping Station Demo", "templating": {...} },
|
||||||
|
"folderId": 0,
|
||||||
|
"overwrite": true
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"nodeId": "ps_demo",
|
||||||
|
"softwareType": "pumpingStation",
|
||||||
|
"uid": "a1b2c3d4e5f6",
|
||||||
|
"title": "Pumping Station Demo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|:---|:---|
|
||||||
|
| `topic` | Always `'create'` — signals a dashboard-upsert HTTP envelope. |
|
||||||
|
| `url` | `grafanaUpsertUrl()` = `<protocol>://<host>:<port>/api/dashboards/db`. |
|
||||||
|
| `method` | Always `POST`. |
|
||||||
|
| `headers.Authorization` | Present only when `bearerToken` is configured; omitted otherwise. |
|
||||||
|
| `payload.dashboard` | The composed Grafana dashboard JSON (template + templating vars filled in). |
|
||||||
|
| `payload.dashboard.uid` | `sha1(softwareType:nodeId).slice(0, 12)` — stable across re-deploys. |
|
||||||
|
| `meta.*` | Correlation fields for the downstream consumer (nodeId, softwareType, uid, title). |
|
||||||
|
|
||||||
|
Inbound `msg` fields propagate via spread (`{...msg, ...envelope}`) so any caller-supplied correlation / trace fields survive.
|
||||||
|
|
||||||
|
> Port 1 (InfluxDB telemetry) and Port 2 (registration / control plumbing) are **unused** — dashboardAPI has no measurements and does not register with a parent. See [Reference — Architecture](Reference-Architecture#output-ports).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The new bit — no BaseNodeAdapter / BaseDomain
|
||||||
|
|
||||||
|
Most EVOLV nodes extend `BaseNodeAdapter` + `BaseDomain` from `generalFunctions/`. `dashboardAPI` does **not** — per `OPEN_QUESTIONS.md` (2026-05-10) the decision is to keep a bespoke adapter until `BaseNodeAdapter` grows passive / HTTP-only flags.
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
|
||||||
|
- No `generalFunctions/src/configs/dashboardapi.json` — `BaseDomain`'s constructor unconditionally calls `configManager.getConfig(ctor.name)` and would throw. The local `dependencies/dashboardapi/dashboardapiConfig.json` is for the editor menu endpoint, not the runtime config pipeline.
|
||||||
|
- No periodic output — `BaseNodeAdapter._emitOutputs()` / `outputUtils.formatMsg` assumes a delta-compressed Port 0 / 1 stream; dashboardAPI emits HTTP-shaped messages instead.
|
||||||
|
- No registration to a parent — `BaseNodeAdapter._scheduleRegistration` would emit a spurious `child.register` of its own.
|
||||||
|
- No status badge / tick / measurements / children of its own.
|
||||||
|
|
||||||
|
dashboardAPI uses the shared `commandRegistry` (canonical-topic naming + alias-with-deprecation) and stops there. See [Reference — Architecture](Reference-Architecture#why-no-basenodeadapter--basedomain) for the full rationale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Need more?
|
||||||
|
|
||||||
|
| Page | What you'll find |
|
||||||
|
|:---|:---|
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Full topic contract, config schema, child resolution rules, template alias table |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, HTTP-endpoint lifecycle, template loader, UID stability, graph walk |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped example flows + debug recipes |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Legacy filename drift, stub flows, missing template handling, open questions |
|
||||||
|
|
||||||
|
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) · [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) · [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
|
||||||
294
wiki/Reference-Architecture.md
Normal file
294
wiki/Reference-Architecture.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# Reference — Architecture
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Code structure for `dashboardAPI`: the (intentionally shallow) three-tier layout, the command registry, the dashboard composition pipeline, the HTTP-endpoint event lifecycle, and the output-port pipeline. For an intuitive overview, return to [Home](Home).
|
||||||
|
>
|
||||||
|
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Three-tier code layout
|
||||||
|
|
||||||
|
```
|
||||||
|
nodes/dashboardAPI/
|
||||||
|
|
|
||||||
|
+-- dashboardapi.js entry: RED.nodes.registerType('dashboardapi', NodeClass)
|
||||||
|
| (legacy lowercase filename — see Limitations)
|
||||||
|
|
|
||||||
|
+-- dashboardapi.html editor: form + oneditprepare / oneditsave
|
||||||
|
| (legacy lowercase filename — see Limitations)
|
||||||
|
|
|
||||||
|
+-- src/
|
||||||
|
| nodeClass.js passive adapter — buildConfig + createRegistry + input dispatch
|
||||||
|
| DOES NOT extend BaseNodeAdapter
|
||||||
|
| specificClass.js DashboardApi service — loadTemplate / buildDashboard /
|
||||||
|
| generateDashboardsForGraph / extractChildren
|
||||||
|
| DOES NOT extend BaseDomain
|
||||||
|
| |
|
||||||
|
| +-- commands/
|
||||||
|
| index.js topic descriptors (child.register only)
|
||||||
|
| handlers.js resolveChildSource + registerChild handler
|
||||||
|
|
|
||||||
|
+-- config/ Grafana JSON templates, one per softwareType
|
||||||
|
| aeration.json machineGroup.json pumpingStation.json
|
||||||
|
| dashboardapi.json measurement.json reactor.json
|
||||||
|
| machine.json monster.json settler.json
|
||||||
|
| valve.json valveGroupControl.json
|
||||||
|
|
|
||||||
|
+-- dependencies/
|
||||||
|
| dashboardapi/
|
||||||
|
| dashboardapiConfig.json editor menu config (NOT runtime config)
|
||||||
|
|
|
||||||
|
+-- examples/
|
||||||
|
| basic.flow.json currently stubs — see Examples & Limitations
|
||||||
|
| integration.flow.json
|
||||||
|
| edge.flow.json
|
||||||
|
|
|
||||||
|
+-- test/
|
||||||
|
basic/ structure-module-load test
|
||||||
|
integration/ structure-examples test
|
||||||
|
edge/ structure-examples-node-type test
|
||||||
|
helpers/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tier responsibilities
|
||||||
|
|
||||||
|
| Tier | File | What it owns | Touches `RED.*` |
|
||||||
|
|:---|:---|:---|:---:|
|
||||||
|
| entry | `dashboardapi.js` | `RED.nodes.registerType('dashboardapi', ...)`. Admin endpoints: `GET /dashboardapi/menu.js` (logger menu) + `GET /dashboardapi/configData.js` (editor metadata). | Yes |
|
||||||
|
| nodeClass | `src/nodeClass.js` | Builds runtime config via `configManager.buildConfig`. Creates command registry via `createRegistry(commands)`. Attaches `input` and `close` handlers. **No tick loop, no status badge, no Port 1 / 2 emissions.** Sets a one-shot red `dashboardapi error` status on dispatch failure. | Yes |
|
||||||
|
| specificClass | `src/specificClass.js` | Pure dashboard composition: template loading, UID derivation, templating-var fill, child graph walk, links generation, upsert request shaping. No `RED.*` calls. | No |
|
||||||
|
|
||||||
|
`specificClass` is small (~210 lines) and self-contained — no concern modules. The complexity surface is too narrow to warrant a `concerns/` split.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why no BaseNodeAdapter / BaseDomain
|
||||||
|
|
||||||
|
The decision is documented in `OPEN_QUESTIONS.md` (2026-05-10) and surfaced in `CONTRACT.md`. Four concrete blockers:
|
||||||
|
|
||||||
|
1. **No platform config JSON.** `BaseDomain`'s constructor unconditionally calls `configManager.getConfig(ctor.name)` against `generalFunctions/src/configs/<n>.json`. There is no `dashboardapi.json` in `generalFunctions` — the local `dependencies/dashboardapi/dashboardapiConfig.json` is for the editor menu endpoint only. Adding a platform config JUST to satisfy the base class would be a synthetic decision.
|
||||||
|
2. **No periodic output.** `BaseNodeAdapter._emitOutputs()` and `outputUtils.formatMsg` assume a delta-compressed Port 0 / 1 telemetry stream tied to a tick loop. dashboardAPI emits HTTP envelopes asynchronously on inbound events; the formatter pipeline would coerce these into the wrong shape.
|
||||||
|
3. **No parent registration.** `BaseNodeAdapter._scheduleRegistration` automatically emits a `child.register` on Port 2 at startup. dashboardAPI is a **sink** for `child.register`, not a source — emitting one of its own would feed into other dashboardAPI instances and cause loops.
|
||||||
|
4. **No status badge, no tick, no measurements, no children of its own.** Most of the base-class machinery would be inert or actively harmful.
|
||||||
|
|
||||||
|
What dashboardAPI **does** reuse from `generalFunctions/`:
|
||||||
|
|
||||||
|
- `configManager` (for `buildConfig`)
|
||||||
|
- `createRegistry` + the canonical-topic / alias-with-deprecation pattern
|
||||||
|
- `logger`
|
||||||
|
- `MenuManager` (for the editor menu endpoint)
|
||||||
|
|
||||||
|
That's enough common platform surface to keep the node aligned with EVOLV conventions without inheriting machinery it can't use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Command registry
|
||||||
|
|
||||||
|
`src/commands/index.js` declares one descriptor:
|
||||||
|
|
||||||
|
```js
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
topic: 'child.register',
|
||||||
|
aliases: ['registerChild'],
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
handler: handlers.registerChild,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
`createRegistry(commands, { logger })` returns a dispatcher with built-in alias-with-deprecation: the first time `msg.topic === 'registerChild'` fires, the logger emits a one-time deprecation warning; thereafter the alias is silently mapped to the canonical handler.
|
||||||
|
|
||||||
|
### `child.register` handler — resolution pipeline
|
||||||
|
|
||||||
|
`src/commands/handlers.js` `registerChild(source, msg, ctx)`:
|
||||||
|
|
||||||
|
1. **Resolve the child source** via `resolveChildSource(msg.payload, ctx)`:
|
||||||
|
- If `payload.source.config` exists → use `payload.source` directly (inline shape A).
|
||||||
|
- Else if `payload.config` exists → wrap as `{ config: payload.config }` (inline shape B).
|
||||||
|
- Else if `typeof payload === 'string'` → treat as a node id and resolve via `RED.nodes.getNode(id)` → fall back to `ctx.node._flow.getNode(id)`.
|
||||||
|
2. **Throw** `Missing or invalid child node` if neither path yields a `.config` — the nodeClass's catch sets the red `dashboardapi error` status badge and re-throws via `node.error`.
|
||||||
|
3. **Walk the graph** via `source.generateDashboardsForGraph(childSource, {includeChildren: msg.includeChildren ?? true})`.
|
||||||
|
4. **Emit one Port-0 envelope** per generated dashboard, with the `{...msg, topic: 'create', ...}` spread so caller fields propagate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard composition pipeline
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
in[child.register payload]:::input --> res[resolveChildSource<br/>RED.nodes.getNode → _flow.getNode → inline]
|
||||||
|
res --> walk[generateDashboardsForGraph<br/>root + direct children if includeChildren]
|
||||||
|
walk --> bld[buildDashboard per node]
|
||||||
|
bld --> tpl[loadTemplate softwareType<br/>config/-st-.json with case-insensitive fallback<br/>+ machineGroupControl → machineGroup.json alias]
|
||||||
|
tpl --> uid[stableUid<br/>sha1 softwareType:nodeId .slice 0,12]
|
||||||
|
bld --> vars[updateTemplatingVar<br/>measurement = softwareType_nodeId<br/>bucket = position-based default or override]
|
||||||
|
walk --> links[Add root.links of child uid + slugify title]
|
||||||
|
links --> shape[buildUpsertRequest<br/>dashboard + folderId 0 + overwrite true]
|
||||||
|
shape --> emit[ctx.send one msg per dashboard<br/>topic 'create', url, method, headers, payload, meta]
|
||||||
|
emit --> out[Port 0]
|
||||||
|
classDef input fill:#dddddd,color:#000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template selection
|
||||||
|
|
||||||
|
`_templateFileForSoftwareType(softwareType)` tries these candidates in order:
|
||||||
|
|
||||||
|
1. `config/<softwareType>.json` (exact case)
|
||||||
|
2. `config/<softwareType.toLowerCase()>.json` (case-insensitive fallback)
|
||||||
|
3. `config/machineGroup.json` — only when `softwareType === 'machineGroupControl'` (one-off alias)
|
||||||
|
|
||||||
|
A missing template logs at `warn` level (`No dashboard template found for softwareType=<st>`) and the matching dashboard is skipped (no error thrown, the rest of the graph walk continues).
|
||||||
|
|
||||||
|
Currently shipped templates in `config/`:
|
||||||
|
|
||||||
|
| Template | Maps to softwareType |
|
||||||
|
|:---|:---|
|
||||||
|
| `aeration.json` | aeration |
|
||||||
|
| `dashboardapi.json` | dashboardapi (this node) |
|
||||||
|
| `machine.json` | (likely `rotatingmachine` / `machine` — verify when reviewing) |
|
||||||
|
| `machineGroup.json` | `machineGroupControl` (via alias) |
|
||||||
|
| `measurement.json` | measurement |
|
||||||
|
| `monster.json` | monster |
|
||||||
|
| `pumpingStation.json` | pumpingStation |
|
||||||
|
| `reactor.json` | reactor |
|
||||||
|
| `settler.json` | settler |
|
||||||
|
| `valve.json` | valve |
|
||||||
|
| `valveGroupControl.json` | valveGroupControl |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The exact softwareType ↔ template mapping (esp. `machine.json` vs the lowercase `rotatingmachine` softwareType emitted by `rotatingMachine`'s `functionality.softwareType`) needs verification during the full review — flagged.
|
||||||
|
|
||||||
|
### UID stability
|
||||||
|
|
||||||
|
`stableUid(input) = sha1(input).slice(0, 12)` — the same `softwareType:nodeId` always yields the same dashboard UID. Combined with `overwrite: true` in the upsert payload, this makes the operation idempotent: re-deploying the EVOLV flow re-runs the upsert with the same UID and Grafana replaces the existing dashboard rather than creating a duplicate.
|
||||||
|
|
||||||
|
### Position-based bucket fallback
|
||||||
|
|
||||||
|
When `defaultBucket` is empty AND `bucketMap[position]` has no entry:
|
||||||
|
|
||||||
|
| `positionVsParent` | Bucket used |
|
||||||
|
|:---|:---|
|
||||||
|
| `upstream` (case-insensitive) | `lvl1` |
|
||||||
|
| `downstream` (case-insensitive) | `lvl3` |
|
||||||
|
| any other / absent | `lvl2` |
|
||||||
|
|
||||||
|
Overridden by (in order): `config.defaultBucket` → `config.bucketMap[position]` → the table above. `INFLUXDB_BUCKET` env is read in `_buildConfig` and lands in `config.defaultBucket`.
|
||||||
|
|
||||||
|
### Root → child links
|
||||||
|
|
||||||
|
When `includeChildren=true` and the root has ≥ 1 direct child, the root dashboard's `links[]` is augmented with one entry per child:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
title: childTitle,
|
||||||
|
url: `/d/${childUid}/${slugify(childTitle)}`,
|
||||||
|
tags: [],
|
||||||
|
targetBlank: false,
|
||||||
|
keepTime: true,
|
||||||
|
keepVariables: true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`slugify` is lowercase-kebab-case, truncated to 60 chars. `keepTime` and `keepVariables` are Grafana's "preserve dashboard state across navigation" flags — clicking a link keeps the time range and templating selections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lifecycle — what one event does
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant emitter as any EVOLV node
|
||||||
|
participant dash as dashboardAPI (nodeClass)
|
||||||
|
participant cr as commandRegistry
|
||||||
|
participant api as DashboardApi (specificClass)
|
||||||
|
participant out as Port 0
|
||||||
|
participant http as http request (downstream)
|
||||||
|
participant grafana as Grafana HTTP API
|
||||||
|
|
||||||
|
emitter->>dash: msg{topic: 'child.register', payload}
|
||||||
|
dash->>cr: dispatch(msg, source, ctx)
|
||||||
|
cr->>cr: canonicalise topic (alias→canonical, log deprecation once)
|
||||||
|
cr->>api: handlers.registerChild(source, msg, ctx)
|
||||||
|
api->>api: resolveChildSource(payload, ctx)
|
||||||
|
alt source missing
|
||||||
|
api-->>dash: throw 'Missing or invalid child node'
|
||||||
|
dash->>dash: node.status({fill:'red','dashboardapi error'})
|
||||||
|
dash->>dash: node.error(err, msg)
|
||||||
|
else source resolved
|
||||||
|
api->>api: generateDashboardsForGraph(childSource, {includeChildren})
|
||||||
|
api->>api: buildDashboard(root) → loadTemplate + stableUid + templating
|
||||||
|
api->>api: extractChildren → buildDashboard per child
|
||||||
|
api->>api: rootDash.links += child links
|
||||||
|
loop per dashboard in results
|
||||||
|
api->>out: ctx.send({...msg, topic:'create', url, method, headers, payload, meta})
|
||||||
|
out->>http: msg flows to downstream http request node
|
||||||
|
http->>grafana: POST /api/dashboards/db
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
One inbound event yields **N outbound HTTP envelopes**, where N = 1 (root) + count(direct children) when `includeChildren=true`, or 1 when `includeChildren=false`.
|
||||||
|
|
||||||
|
There is no FSM. There is no tick loop. There is no `state.emitter`. The node is event-driven and stateless — every `child.register` is handled independently and discarded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output ports
|
||||||
|
|
||||||
|
| Port | Carries | Sample shape |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| 0 (process) | One `topic: 'create'` HTTP envelope per generated dashboard | `{topic:'create', url, method:'POST', headers, payload:{dashboard,folderId:0,overwrite:true}, meta}` |
|
||||||
|
| 1 (telemetry) | **Unused.** No measurements; nothing emitted. | — |
|
||||||
|
| 2 (registration / control) | **Unused.** dashboardAPI is a sink for `child.register`, not a source. | — |
|
||||||
|
|
||||||
|
Port 0 deliberately diverges from the standard "process data + delta-compressed" convention: the envelope is a fully-formed HTTP request, shaped for a downstream `http request` core node. Caller-supplied `msg.*` fields propagate via the `{...msg, ...envelope}` spread so correlation / trace fields survive the hop.
|
||||||
|
|
||||||
|
> Per `.claude/rules/output-coverage.md`: this node has a small output surface (one Port-0 msg shape), and no tick / FSM states — the manifest is correspondingly small. The standard "every output, every state" sweep collapses to "every key in the envelope is present whenever a dashboard is generated; nothing is emitted when resolution fails."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event sources
|
||||||
|
|
||||||
|
| Source | Where it fires | What it triggers |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| Inbound `msg.topic` | Node-RED input wire on Port 0 input | `commandRegistry.dispatch` → `handlers.registerChild` |
|
||||||
|
| Admin HTTP `GET /dashboardapi/menu.js` | Editor first-load | `MenuManager.createEndpoint('dashboardapi', ['logger'])` returns JS bootstrap |
|
||||||
|
| Admin HTTP `GET /dashboardapi/configData.js` | Editor first-load | Reads `dependencies/dashboardapi/dashboardapiConfig.json` and returns it as a JS-attached global on `window.EVOLV.nodes.dashboardapi.config` |
|
||||||
|
| `node.on('close')` | Node-RED redeploy / shutdown | No-op (handler exists but only calls `done()`) |
|
||||||
|
|
||||||
|
There is no `setInterval`, no `state.emitter`, no `child.measurements.emitter`. The node sleeps until `child.register` arrives.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to start reading
|
||||||
|
|
||||||
|
| If you're changing... | Read first |
|
||||||
|
|:---|:---|
|
||||||
|
| Adding a new topic / changing the alias map | `src/commands/index.js` + `src/commands/handlers.js` |
|
||||||
|
| Payload resolution rules (string id / inline source / inline config) | `src/commands/handlers.js` `resolveChildSource` + `resolveChildNode` |
|
||||||
|
| Grafana URL composition / bearer token / headers | `src/specificClass.js` `grafanaUpsertUrl` + `handlers.registerChild` header logic |
|
||||||
|
| Template selection, alias rules, missing-template behaviour | `src/specificClass.js` `_templateFileForSoftwareType` + `loadTemplate` |
|
||||||
|
| UID derivation, dashboard composition, links | `src/specificClass.js` `buildDashboard` + `generateDashboardsForGraph` |
|
||||||
|
| Bucket fallback (position → lvl1/lvl2/lvl3) | `src/specificClass.js` `defaultBucketForPosition` |
|
||||||
|
| Editor form ↔ config keys | `dashboardapi.html` + `src/nodeClass.js` `_buildConfig` |
|
||||||
|
| Editor menu / config endpoints | `dashboardapi.js` (entry, admin endpoints) + `dependencies/dashboardapi/dashboardapiConfig.json` |
|
||||||
|
| Template content for a new EVOLV node type | `config/<softwareType>.json` — copy the closest existing one and adjust |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Topic + config + template alias map |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Filename drift, stub flows, open questions |
|
||||||
|
| [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |
|
||||||
|
| [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout (dashboardAPI is an exception — Port 0 carries HTTP envelopes) |
|
||||||
243
wiki/Reference-Contracts.md
Normal file
243
wiki/Reference-Contracts.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# Reference — Contracts
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Full topic contract, configuration schema, child-resolution rules, and Port-0 envelope spec for `dashboardAPI`. Source of truth: `src/commands/index.js`, `src/commands/handlers.js`, `src/specificClass.js`, `src/nodeClass.js`, and `dependencies/dashboardapi/dashboardapiConfig.json`.
|
||||||
|
>
|
||||||
|
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
|
||||||
|
>
|
||||||
|
> For an intuitive overview, return to [Home](Home).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Topic contract
|
||||||
|
|
||||||
|
The registry lives in `src/commands/index.js`. dashboardAPI has **one** canonical input topic.
|
||||||
|
|
||||||
|
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||||
|
|
||||||
|
| Canonical topic | Aliases | Payload | Unit | Effect |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `child.register` | `registerChild` | any | — | — |
|
||||||
|
|
||||||
|
<!-- END AUTOGEN: topic-contract -->
|
||||||
|
|
||||||
|
The `registerChild` alias logs a one-time deprecation warning on first use. There is **no HTTP endpoint contract** for dashboardAPI as a Node-RED node — it is an input-on-wire only. The outbound HTTP call shape is documented in [Port-0 envelope](#port-0-envelope-data-model) below.
|
||||||
|
|
||||||
|
### Payload resolution rules
|
||||||
|
|
||||||
|
| Payload shape | Resolved as | Source code |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `{source: {config: {...}}, ...}` | `payload.source` — use directly | `handlers.js` `resolveChildSource` line 6 |
|
||||||
|
| `{config: {...}}` | `{config: payload.config}` — wrap minimally | `handlers.js` `resolveChildSource` line 7 |
|
||||||
|
| `"<node-id>"` (bare string) | `RED.nodes.getNode(id).source` → fallback `node._flow.getNode(id).source` | `handlers.js` `resolveChildNode` |
|
||||||
|
| anything else | `null` → throws `'Missing or invalid child node'` | `handlers.js` `registerChild` line 30 |
|
||||||
|
|
||||||
|
`msg.includeChildren` (default `true`) controls graph-walk depth: `true` walks `extractChildren(rootSource)` and emits one dashboard per discovered child plus the root; `false` emits just the root dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data model — Port-0 envelope
|
||||||
|
|
||||||
|
<!-- BEGIN AUTOGEN: data-model — populate via wiki-gen tool (TODO) -->
|
||||||
|
|
||||||
|
dashboardAPI **has no domain output** — it does not extend `BaseDomain` and does not implement `getOutput()`. Port 0 carries one **HTTP request envelope** per generated dashboard, shaped for a downstream `http request` core node:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
topic: 'create',
|
||||||
|
url: 'http://<grafana-host>:<grafana-port>/api/dashboards/db',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer <token>' // only when grafanaConnector.bearerToken is set
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
dashboard: { uid: '<12-char-sha1>', title: '<node-name>', templating: {...}, ... },
|
||||||
|
folderId: 0,
|
||||||
|
overwrite: true
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
nodeId: '<from config.general.id or .name>',
|
||||||
|
softwareType: '<from config.functionality.softwareType>',
|
||||||
|
uid: '<same 12-char-sha1>',
|
||||||
|
title: '<same node name>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Port 1 (InfluxDB telemetry) and Port 2 (registration / control plumbing) are **unused** — dashboardAPI has no measurements and does not register with a parent.
|
||||||
|
|
||||||
|
<!-- END AUTOGEN: data-model -->
|
||||||
|
|
||||||
|
### Envelope fields
|
||||||
|
|
||||||
|
| Key | Type | Source | Notes |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `topic` | string | constant `'create'` | Signals "Grafana dashboard upsert". |
|
||||||
|
| `url` | string | `grafanaUpsertUrl()` | `${protocol}://${host}:${port}/api/dashboards/db`. |
|
||||||
|
| `method` | string | constant `'POST'` | — |
|
||||||
|
| `headers.Accept` | string | constant | `application/json` |
|
||||||
|
| `headers.Content-Type` | string | constant | `application/json` |
|
||||||
|
| `headers.Authorization` | string | absent | `Bearer ${bearerToken}` | **Omitted entirely** when `bearerToken` is empty. |
|
||||||
|
| `payload.dashboard` | object | `buildUpsertRequest({dashboard, folderId, overwrite}).dashboard` | The composed Grafana dashboard JSON. |
|
||||||
|
| `payload.folderId` | integer | constant `0` | Root folder. Not configurable. |
|
||||||
|
| `payload.overwrite` | boolean | constant `true` | Required for idempotent re-deploys. |
|
||||||
|
| `meta.nodeId` | string | `config.general.id` or `config.general.name` or `softwareType` | Correlation id. |
|
||||||
|
| `meta.softwareType` | string | `config.functionality.softwareType` (case-insensitive lookup) | Used for template selection. |
|
||||||
|
| `meta.uid` | string | `sha1(softwareType:nodeId).slice(0, 12)` | Stable across re-deploys — same `(softwareType, nodeId)` → same UID. |
|
||||||
|
| `meta.title` | string | `config.general.name` or `nodeId` | Human-readable dashboard title. |
|
||||||
|
|
||||||
|
**`msg` propagation:** inbound `msg.*` fields are merged via `{...msg, topic:'create', ...}` spread — caller-supplied correlation / trace fields (e.g. `msg._msgid`, `msg.requestId`) survive the hop.
|
||||||
|
|
||||||
|
### Dashboard composition
|
||||||
|
|
||||||
|
For each generated dashboard, `buildDashboard({nodeConfig, positionVsParent})` performs:
|
||||||
|
|
||||||
|
1. **Template load** — `loadTemplate(softwareType)` from `config/<softwareType>.json` (case-insensitive fallback, `machineGroupControl → machineGroup.json` alias). Missing template → logs `warn` and returns `null` (the dashboard is skipped from the output).
|
||||||
|
2. **UID stamp** — `dashboard.uid = stableUid(softwareType:nodeId)`.
|
||||||
|
3. **Title stamp** — `dashboard.title = config.general.name || nodeId`.
|
||||||
|
4. **Tags merge** — existing `template.tags` + `['EVOLV', softwareType, positionVsParent]` (deduplicated, empty values filtered).
|
||||||
|
5. **Templating var fill** — `dashboard.templating.list[]` entries named `measurement` and `bucket` are mutated in place:
|
||||||
|
- `measurement` ← `${softwareType}_${nodeId}` (used as InfluxDB measurement name in panel queries).
|
||||||
|
- `bucket` ← resolved bucket (see [Bucket resolution](#bucket-resolution) below).
|
||||||
|
6. **Links append** (root dashboard only, when `includeChildren=true` and `children.length > 0`) — one `{type:'link', title, url:'/d/<uid>/<slug>', keepTime, keepVariables}` entry per direct child.
|
||||||
|
|
||||||
|
If `dashboard.templating.list` is not an array or the named variable doesn't exist, the templating step is a no-op (no error).
|
||||||
|
|
||||||
|
### Bucket resolution
|
||||||
|
|
||||||
|
`bucket` (the InfluxDB bucket templating var) is resolved in priority order:
|
||||||
|
|
||||||
|
| Priority | Source | When applied |
|
||||||
|
|:---:|:---|:---|
|
||||||
|
| 1 | `config.defaultBucket` (editor field or `INFLUXDB_BUCKET` env) | When set to a non-empty string |
|
||||||
|
| 2 | `config.bucketMap[positionVsParent]` | When the position has an entry |
|
||||||
|
| 3 | `defaultBucketForPosition(positionVsParent)` | Falls through — `upstream → lvl1`, `downstream → lvl3`, else `lvl2` |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Priorities 1 and 2 read order from `specificClass.js` `buildDashboard`. Verify against the editor's intended semantics during full review — "global override beats per-position map" is the current behaviour. Flagged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration schema — editor form to config keys
|
||||||
|
|
||||||
|
Source of truth: `dependencies/dashboardapi/dashboardapiConfig.json` + `src/nodeClass.js` `_buildConfig`. The runtime config slice is built by `configManager.buildConfig(name, uiConfig, nodeId, overrides)`.
|
||||||
|
|
||||||
|
### General (`config.general`)
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Notes |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| Name | `general.name` | `'dashboardapi'` | Display label; falls through to nodeId in `meta.title`. |
|
||||||
|
| (auto-assigned) | `general.id` | `null` | Node-RED node id. |
|
||||||
|
| Enable logging | `general.logging.enabled` | `false` (per `_buildConfig`) / `true` (per `dashboardapiConfig.json`) | **Mismatch** — see [Limitations](Reference-Limitations#config-default-mismatch). |
|
||||||
|
| Log level | `general.logging.logLevel` | `'info'` | `debug` / `info` / `warn` / `error`. |
|
||||||
|
|
||||||
|
### Functionality (`config.functionality`)
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Notes |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| (hidden) | `functionality.softwareType` | `'dashboardapi'` | Constant. Set in `_buildConfig` from `this.name.toLowerCase()`. |
|
||||||
|
| (hidden) | `functionality.role` | `'auto ui generator'` | Constant. |
|
||||||
|
|
||||||
|
### Grafana connector (`config.grafanaConnector`)
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Range / values | Where used |
|
||||||
|
|:---|:---|:---|:---|:---|
|
||||||
|
| Protocol | `grafanaConnector.protocol` | `'http'` | `http` / `https` | `grafanaUpsertUrl()` |
|
||||||
|
| Grafana Host | `grafanaConnector.host` | `'localhost'` | hostname / IP | `grafanaUpsertUrl()` |
|
||||||
|
| Grafana Port | `grafanaConnector.port` | `3000` | 1–65535 (`Number(uiConfig.port \|\| 3000)`) | `grafanaUpsertUrl()` |
|
||||||
|
| Bearer Token | `grafanaConnector.bearerToken` | `''` | string (Grafana service-account token) | `Authorization: Bearer ...` header; omitted when empty |
|
||||||
|
|
||||||
|
### Bucket configuration
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Notes |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| InfluxDB Bucket | `defaultBucket` | `''` → falls back to `process.env.INFLUXDB_BUCKET` → position default | Set in `_buildConfig`; consumed by `buildDashboard` templating fill. |
|
||||||
|
| (no editor field) | `bucketMap` | `{}` | Programmatic only — pass via `uiConfig.bucketMap` or future editor field. |
|
||||||
|
|
||||||
|
### Editor menu / logger fields
|
||||||
|
|
||||||
|
The `dashboardapi.html` template invokes `window.EVOLV.nodes.dashboardapi.loggerMenu.initEditor / saveEditor` via the shared `MenuManager`-served `/dashboardapi/menu.js` endpoint. The logger fields (`enableLog`, `logLevel`) are persisted on the node via the standard EVOLV editor menu pattern.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **Editor `defaults` use legacy field names.** `dashboardapi.html` declares `{enableLog, logLevel}` as Node-RED defaults but the runtime config reads `general.logging.{enabled, logLevel}`. The bridge is the shared logger menu (`MenuManager`) — confirm during full review that the editor menu correctly maps `enableLog` → `general.logging.enabled`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template alias map
|
||||||
|
|
||||||
|
`_templateFileForSoftwareType(softwareType)` lookup order:
|
||||||
|
|
||||||
|
| Order | Candidate filename | Notes |
|
||||||
|
|:---:|:---|:---|
|
||||||
|
| 1 | `<softwareType>.json` | Exact case. |
|
||||||
|
| 2 | `<softwareType.toLowerCase()>.json` | Case-insensitive fallback. |
|
||||||
|
| 3 | `machineGroup.json` | **Only** when `softwareType === 'machineGroupControl'` (one-off alias). |
|
||||||
|
|
||||||
|
If none of the candidates exist in `config/`, the logger emits `No dashboard template found for softwareType=<st>` at `warn` level and `loadTemplate` returns `null`. `buildDashboard` then logs `Skipping dashboard generation: no template for softwareType=<st>` and returns `null`; `generateDashboardsForGraph` skips that node and continues with the rest of the graph walk.
|
||||||
|
|
||||||
|
Currently shipped templates:
|
||||||
|
|
||||||
|
| softwareType (canonical) | Template file | Notes |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `aeration` | `aeration.json` | — |
|
||||||
|
| `dashboardapi` | `dashboardapi.json` | Self-template (when a dashboardAPI registers as a child of another dashboardAPI — unusual). |
|
||||||
|
| `machine` (or `rotatingmachine`) | `machine.json` | softwareType to verify in full review — flagged. |
|
||||||
|
| `machineGroupControl` | `machineGroup.json` | Via one-off alias. |
|
||||||
|
| `measurement` | `measurement.json` | — |
|
||||||
|
| `monster` | `monster.json` | — |
|
||||||
|
| `pumpingStation` | `pumpingStation.json` | — |
|
||||||
|
| `reactor` | `reactor.json` | — |
|
||||||
|
| `settler` | `settler.json` | — |
|
||||||
|
| `valve` | `valve.json` | — |
|
||||||
|
| `valveGroupControl` | `valveGroupControl.json` | — |
|
||||||
|
|
||||||
|
Adding support for a new EVOLV node type = drop a `config/<newType>.json` file matching the `softwareType` lowercase name (or add an alias arm to `_templateFileForSoftwareType`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Child resolution (NOT a registry)
|
||||||
|
|
||||||
|
dashboardAPI does **not** maintain a child registry of its own. There is no `_registeredChildren` map, no `child.register` → `child.unregister` lifecycle, no parent → child emitter wiring. Every inbound `child.register` is a **one-shot** dashboard generation:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
src["any EVOLV node<br/>(has functionality.softwareType)"]:::other -->|child.register| dash[dashboardAPI<br/>Utility]:::neutral
|
||||||
|
dash --> resolve["resolveChildSource(payload, ctx)<br/>RED.nodes.getNode → _flow.getNode → inline"]
|
||||||
|
resolve --> walk["generateDashboardsForGraph(childSource, {includeChildren})"]
|
||||||
|
walk --> emit["emit one msg per dashboard<br/>topic='create'"]
|
||||||
|
emit --> http[(downstream<br/>http request node)]
|
||||||
|
classDef neutral fill:#dddddd,color:#000
|
||||||
|
classDef other fill:#ffffff,stroke:#666
|
||||||
|
```
|
||||||
|
|
||||||
|
### What graph walk reads from the child source
|
||||||
|
|
||||||
|
`extractChildren(rootSource)` reads `rootSource.childRegistrationUtils.registeredChildren` (a Map). For each `entry`:
|
||||||
|
|
||||||
|
- `entry.child` — the child source object (must have `.config`).
|
||||||
|
- `entry.position` (or `child.positionVsParent`) — used for the bucket fallback and tag composition.
|
||||||
|
|
||||||
|
Children without a `.config` are silently skipped. If `rootSource.childRegistrationUtils` is absent or `registeredChildren.values` is not a function, the result is an empty array — just the root dashboard is emitted.
|
||||||
|
|
||||||
|
| Inbound softwareType | Filter | Side effect |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| any | child has `functionality.softwareType` AND the matching `config/*.json` exists | Loads template; emits one upsert msg per dashboard in the walk. |
|
||||||
|
| any | child has `functionality.softwareType` but the template is missing | Warns and skips that node's dashboard. No error thrown. Graph walk continues. |
|
||||||
|
| absent / malformed | `resolveChildSource` returns null | Throws `Missing or invalid child node` → nodeClass sets red status, calls `node.error`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, lifecycle, graph walk |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Filename drift, stub flows, open questions |
|
||||||
|
| [EVOLV — Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
|
||||||
|
| [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 layout (dashboardAPI is an exception — Port 0 carries HTTP envelopes) |
|
||||||
171
wiki/Reference-Examples.md
Normal file
171
wiki/Reference-Examples.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# Reference — Examples
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Every example flow shipped under `nodes/dashboardAPI/examples/`, plus how to load them, what they show, and the debug recipes that go with them. Live source: `nodes/dashboardAPI/examples/`.
|
||||||
|
>
|
||||||
|
> Pending full node review (2026-05). The shipped example flows are **stubs** — they wire up the node but the inject payloads do not yet match the `child.register` resolver's expected shape. Working wiring patterns are documented inline below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shipped examples
|
||||||
|
|
||||||
|
| File | Tier | Dependencies | What it shows | Status |
|
||||||
|
|:---|:---:|:---|:---|:---|
|
||||||
|
| `basic.flow.json` | 1 | EVOLV only | Inject a `ping` topic into a stand-alone dashboardAPI node + debug tap on Port 0. | ⏳ **Stub** — inject topic is `ping`, not `child.register`; the registry will silently drop the msg. |
|
||||||
|
| `integration.flow.json` | 2 | EVOLV only | Inject a `registerChild` alias topic with a bare-string node id (`'example-child-id'`) + debug tap. | ⏳ **Stub** — the bare-string id resolves to `null` via `RED.nodes.getNode`; throws `'Missing or invalid child node'`. |
|
||||||
|
| `edge.flow.json` | 3 | EVOLV only | Inject an unknown topic to confirm the dispatcher silently drops it. | ✓ Works as a registry-coverage probe. |
|
||||||
|
|
||||||
|
All three are tracked for replacement in the next wiki-cleanup pass — see [Limitations — Example flow stubs](Reference-Limitations#example-flow-stubs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Loading a flow
|
||||||
|
|
||||||
|
### Via the editor
|
||||||
|
|
||||||
|
1. Open the Node-RED editor at `http://localhost:1880`.
|
||||||
|
2. Menu → Import → drag the JSON file.
|
||||||
|
3. Click Deploy.
|
||||||
|
|
||||||
|
### Via the Admin API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data @nodes/dashboardAPI/examples/basic.flow.json \
|
||||||
|
http://localhost:1880/flows
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working wiring patterns
|
||||||
|
|
||||||
|
These are the shapes that actually exercise the resolver. Use them as the basis for any new example flow until the stubs above are replaced.
|
||||||
|
|
||||||
|
### Wiring pattern A — inline `source` payload (no real EVOLV node needed)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "inject",
|
||||||
|
"topic": "child.register",
|
||||||
|
"props": [
|
||||||
|
{"p": "topic", "vt": "str"},
|
||||||
|
{"p": "payload", "v": "{\"source\":{\"config\":{\"functionality\":{\"softwareType\":\"measurement\"},\"general\":{\"id\":\"pump-a-flow\",\"name\":\"Pump A flow\"}}}}", "vt": "json"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "type": "dashboardapi" },
|
||||||
|
{ "type": "http request", "method": "POST" },
|
||||||
|
{ "type": "debug", "complete": "true" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
What happens:
|
||||||
|
|
||||||
|
1. The inject fires a msg with `topic: 'child.register'` and `payload.source.config.functionality.softwareType = 'measurement'`.
|
||||||
|
2. `resolveChildSource` matches the `payload.source.config` branch and returns `payload.source` directly.
|
||||||
|
3. `loadTemplate('measurement')` reads `config/measurement.json`.
|
||||||
|
4. `stableUid('measurement:pump-a-flow')` → deterministic 12-char hex.
|
||||||
|
5. The Port-0 envelope flows to the debug node AND to the `http request` node which POSTs to Grafana.
|
||||||
|
|
||||||
|
### Wiring pattern B — bare `config` payload
|
||||||
|
|
||||||
|
Same as pattern A but with the outer `source` wrapper dropped:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"payload": "{\"config\":{\"functionality\":{\"softwareType\":\"pumpingStation\"},\"general\":{\"id\":\"ps_demo\",\"name\":\"Pumping Station Demo\"}}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
`resolveChildSource` falls through to the `payload.config` branch and wraps as `{config: payload.config}`. No `childRegistrationUtils` is present, so the graph walk emits only the root dashboard (no children even if `includeChildren=true`).
|
||||||
|
|
||||||
|
### Wiring pattern C — real EVOLV node via Port 2
|
||||||
|
|
||||||
|
The canonical production wiring: any EVOLV node's Port 2 (`registerChild` emission) wired into dashboardAPI's input.
|
||||||
|
|
||||||
|
```text
|
||||||
|
[rotatingMachine] Port 2 ──► [dashboardAPI] Port 0 ──► [http request] ──► Grafana
|
||||||
|
│
|
||||||
|
└─► [debug]
|
||||||
|
```
|
||||||
|
|
||||||
|
The emitting node's `child.register` payload is the bare node id (a string). `resolveChildNode` then runs `RED.nodes.getNode(id)` to fetch the live runtime node and reads `node.source.config`. Walks `node.source.childRegistrationUtils.registeredChildren` so direct children also get dashboards.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Example needed.** A Tier-2 example that wires a real `rotatingMachine` or `pumpingStation` Port 2 to dashboardAPI input is the missing canonical demo. Save as `nodes/dashboardAPI/examples/02-Integration-with-EVOLV-node.json`. Track in `IMPROVEMENTS_BACKLOG.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker compose snippet
|
||||||
|
|
||||||
|
To bring up Node-RED + Grafana (+ optional InfluxDB) for end-to-end testing:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
nodered:
|
||||||
|
build: ./docker/nodered
|
||||||
|
ports: ['1880:1880']
|
||||||
|
volumes:
|
||||||
|
- ./docker/nodered/data:/data/evolv
|
||||||
|
environment:
|
||||||
|
INFLUXDB_BUCKET: lvl2
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:11.0.0
|
||||||
|
ports: ['3000:3000']
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: admin
|
||||||
|
influxdb:
|
||||||
|
image: influxdb:2.7
|
||||||
|
ports: ['8086:8086']
|
||||||
|
```
|
||||||
|
|
||||||
|
A Grafana service account token (created via Grafana UI → Administration → Service accounts) goes into the dashboardAPI's Bearer Token editor field.
|
||||||
|
|
||||||
|
Full file: [EVOLV/docker-compose.yml](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/docker-compose.yml).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debug recipes
|
||||||
|
|
||||||
|
| Symptom | First thing to check | Where to look |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| No HTTP message emitted on Port 0; node shows red `dashboardapi error` status | `resolveChildSource` returned `null`. Check payload shape against [Payload resolution rules](Reference-Contracts#payload-resolution-rules). The most common cause: bare-string id that doesn't match a live Node-RED node. | `src/commands/handlers.js` `resolveChildSource` + `resolveChildNode`. |
|
||||||
|
| Dispatch silently drops msg (no error, no output) | Topic is not `child.register` and not the `registerChild` alias. The registry's catch-all is "no match → ignore". | `src/commands/index.js` + `createRegistry` source in `generalFunctions/`. |
|
||||||
|
| `Skipping dashboard generation: no template for softwareType=<st>` warn | `config/<softwareType>.json` (or its lowercase variant or alias) doesn't exist. | `config/` directory — add a template JSON, or fix the emitting node's `functionality.softwareType`. |
|
||||||
|
| `machineGroupControl` produces no dashboard | The alias maps to `machineGroup.json` — verify that file exists in `config/`. | `_templateFileForSoftwareType` in `src/specificClass.js`. |
|
||||||
|
| Empty `Authorization` header | `bearerToken` not set in editor form — the header is omitted entirely when the token is empty, not set to `'Bearer '`. | Editor → Bearer Token field. |
|
||||||
|
| Wrong InfluxDB bucket in Grafana template variables | `defaultBucket` config (or `INFLUXDB_BUCKET` env) overrides the position-based default. Priority order: `defaultBucket` → `bucketMap[position]` → `defaultBucketForPosition`. | `_buildConfig` in `nodeClass.js` + `defaultBucketForPosition` in `specificClass.js`. |
|
||||||
|
| Dashboard UID changes between deploys | Node id or `softwareType` changed — UID is `sha1(softwareType:nodeId).slice(0, 12)`. Stable only if both are stable. | `stableUid` in `specificClass.js`. |
|
||||||
|
| `registerChild` alias warns once | Expected — deprecation warning on first use only. Migrate caller to `child.register`. | Caller `msg.topic`. |
|
||||||
|
| Grafana 404 on `POST /api/dashboards/db` | Wrong path = check Grafana version. The `/api/dashboards/db` endpoint exists in Grafana 7–11. For newer Grafana with org-scoped endpoints, the upsert URL may differ. | `grafanaUpsertUrl` in `specificClass.js`. |
|
||||||
|
| Grafana 401 / 403 | Bearer token missing, expired, or insufficient permissions. The service account needs at least `Editor` role on the target folder. | Grafana UI → Administration → Service accounts. |
|
||||||
|
| Root dashboard has no `links[]` to children | `includeChildren=false` was passed, OR the root source's `childRegistrationUtils.registeredChildren` is empty / absent. | `generateDashboardsForGraph` + `extractChildren`. |
|
||||||
|
| Editor form shows blank fields after re-open | `oneditprepare` waits for `window.EVOLV.nodes.dashboardapi.loggerMenu` which is loaded by `/dashboardapi/menu.js`. If the menu endpoint 500s, the editor stays blank. | Browser devtools → Network → `menu.js`; check the entry file's logger menu endpoint. |
|
||||||
|
|
||||||
|
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors. Use only for live debugging sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick smoke test (no Grafana required)
|
||||||
|
|
||||||
|
To verify the node loads and the registry dispatches correctly without standing up Grafana:
|
||||||
|
|
||||||
|
1. Import `examples/basic.flow.json` (or any of the stubs).
|
||||||
|
2. Edit the inject node: set topic to `child.register` and payload to a JSON object matching wiring pattern A above.
|
||||||
|
3. Deploy.
|
||||||
|
4. Fire the inject. The debug pane should show a `topic: 'create'` envelope with a populated `payload.dashboard`.
|
||||||
|
5. If `headers.Authorization` is absent, the editor's Bearer Token field is empty — that's correct behaviour.
|
||||||
|
|
||||||
|
The downstream `http request` node is **optional** for the smoke test — the dashboardAPI emits regardless of whether anything POSTs the envelope to Grafana.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Topic + payload resolution + envelope shape |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, lifecycle, graph walk |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Stub flows, filename drift, open questions |
|
||||||
|
| [EVOLV — Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where dashboardAPI fits in a larger plant |
|
||||||
156
wiki/Reference-Limitations.md
Normal file
156
wiki/Reference-Limitations.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Reference — Limitations
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> What `dashboardAPI` does not do, current rough edges, and open questions. Open items live in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` and `.claude/refactor/OPEN_QUESTIONS.md` in the superproject.
|
||||||
|
>
|
||||||
|
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When you would not use this node
|
||||||
|
|
||||||
|
| Scenario | Use instead |
|
||||||
|
|:---|:---|
|
||||||
|
| You maintain Grafana dashboards by hand | Skip dashboardAPI — it will overwrite your customisations on every `child.register` (upsert is `overwrite: true`). |
|
||||||
|
| You need arbitrary Grafana API calls (annotations, alerts, data sources, folders) | A plain `http request` node. dashboardAPI only emits `POST /api/dashboards/db` envelopes. |
|
||||||
|
| You want to forward tick / measurement data to Grafana | This is not what dashboardAPI does. Wire telemetry through Port 1 of an EVOLV process node directly into InfluxDB; Grafana queries InfluxDB. |
|
||||||
|
| You want to use dashboardAPI as a BaseDomain-capable child of something else | Not supported — dashboardAPI does not extend `BaseDomain` and cannot register as a child of `machineGroupControl` / `pumpingStation` / similar. See [No BaseNodeAdapter / BaseDomain](#no-basenodeadapter--basedomain) below. |
|
||||||
|
| You expect EVOLV nodes to auto-discover dashboardAPI | They don't. Port 2 of the emitter must be wired into dashboardAPI's input explicitly. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
### Legacy filename drift
|
||||||
|
|
||||||
|
The entry file and editor HTML are currently lowercase — `dashboardapi.js` and `dashboardapi.html` — rather than `dashboardAPI.js` / `dashboardAPI.html` per the canonical folder-name convention in `.claude/rules/node-architecture.md`.
|
||||||
|
|
||||||
|
The convention rule explicitly calls this out as legacy drift to fix when the file is next touched. A rename is a four-touch change:
|
||||||
|
|
||||||
|
1. `dashboardapi.js` → `dashboardAPI.js`
|
||||||
|
2. `dashboardapi.html` → `dashboardAPI.html`
|
||||||
|
3. `package.json#node-red.nodes` — key remains `dashboardapi` (the Node-RED type id is independent of the filename) but the value becomes `dashboardAPI.js`.
|
||||||
|
4. Superproject submodule references and any `require()` paths.
|
||||||
|
|
||||||
|
The Node-RED **type id** (`dashboardapi`, lowercase, registered via `RED.nodes.registerType('dashboardapi', …)`) must stay `dashboardapi` to avoid breaking existing flows in the wild. The rename is purely the source-file path. Tracked.
|
||||||
|
|
||||||
|
### Example flow stubs
|
||||||
|
|
||||||
|
The three shipped flows (`basic.flow.json`, `integration.flow.json`, `edge.flow.json`) are placeholders. Their inject nodes don't fire a payload that matches the `child.register` resolver:
|
||||||
|
|
||||||
|
| File | Current behaviour | What's wrong |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `basic.flow.json` | Inject `topic: 'ping'` | Not `child.register`; registry silently drops. |
|
||||||
|
| `integration.flow.json` | Inject `topic: 'registerChild'` with `payload: 'example-child-id'` (string) | The string id has no live Node-RED node behind it; `RED.nodes.getNode('example-child-id')` returns null; throws `'Missing or invalid child node'`. |
|
||||||
|
| `edge.flow.json` | Inject `topic: 'doesNotExist'` | Works as a registry-coverage probe (silent drop is correct) but exercises nothing. |
|
||||||
|
|
||||||
|
Working wiring patterns are documented inline in [Reference — Examples](Reference-Examples#working-wiring-patterns). Replacement of the stubs is tracked in `IMPROVEMENTS_BACKLOG.md` (P9 wiki cleanup follow-up).
|
||||||
|
|
||||||
|
### No BaseNodeAdapter / BaseDomain
|
||||||
|
|
||||||
|
Most EVOLV nodes inherit a common adapter / domain base class. dashboardAPI does not. The decision is recorded in `OPEN_QUESTIONS.md` (2026-05-10) — four blockers (no platform config JSON, no periodic output, no parent registration, no status badge / tick / measurements). Until `BaseNodeAdapter` grows passive-mode flags (skip-registration + skip-output-stream), the bespoke adapter shape is the correct compromise.
|
||||||
|
|
||||||
|
Consequence: dashboardAPI cannot be introspected via the standard `getOutput()` channel. Debugging relies on watching Port 0 in a debug node.
|
||||||
|
|
||||||
|
### No domain output / no manifest
|
||||||
|
|
||||||
|
Per `.claude/rules/output-coverage.md`, every node should ship a `test/_output-manifest.md` enumerating every Port-0/1/2 key in populated and degraded states. dashboardAPI's output surface is **one envelope shape**, emitted only when a dashboard is successfully generated — there is no degraded "partial envelope" state to test. The manifest collapses to:
|
||||||
|
|
||||||
|
| Port | Output | Populated state | Degraded state |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| 0 | `{topic, url, method, headers, payload, meta}` envelope | Emitted once per generated dashboard | **Not emitted** — on resolution failure the handler throws and nodeClass sets a red status badge instead |
|
||||||
|
| 1 | (unused) | — | — |
|
||||||
|
| 2 | (unused) | — | — |
|
||||||
|
|
||||||
|
The full output-coverage rule applies prospectively; no backfill manifest exists yet. Tracked.
|
||||||
|
|
||||||
|
### Template discovery is filename-based
|
||||||
|
|
||||||
|
The template lookup is `softwareType` ↔ filename. Renaming a node's `softwareType` (e.g. `rotatingmachine` → `rotatingMachine`) requires either renaming the template file or adding an alias arm in `_templateFileForSoftwareType`. The `machineGroupControl → machineGroup.json` mapping is a one-off alias because the historical filename was abbreviated.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Verify in full review: which softwareType does the current `rotatingMachine` emit? The shipped template is `config/machine.json` — if `rotatingMachine`'s `functionality.softwareType` is `'rotatingmachine'` (lowercase), the case-insensitive fallback won't find it and dashboard generation will warn-and-skip. Flagged.
|
||||||
|
|
||||||
|
### No retry / circuit-breaker on downstream HTTP
|
||||||
|
|
||||||
|
dashboardAPI emits the upsert envelope and is done. If the downstream `http request` node fails (Grafana down, 5xx, network timeout), the dashboard upsert is silently dropped — no retry, no DLQ, no status badge propagation back to dashboardAPI. The caller is responsible for wiring retry logic into the http-request path.
|
||||||
|
|
||||||
|
### `oneditsave` doesn't read all editor fields uniformly
|
||||||
|
|
||||||
|
`dashboardapi.html` `oneditsave` reads `['name', 'protocol', 'host', 'port', 'bearerToken', 'defaultBucket']` via direct DOM lookups, separately from the logger menu's `saveEditor`. Adding a new editor field requires touching both the form HTML and the `oneditsave` whitelist. Mild; not load-bearing.
|
||||||
|
|
||||||
|
### Config default mismatch
|
||||||
|
|
||||||
|
The runtime `_buildConfig` defaults `general.logging.enabled` to `Boolean(config?.general?.logging?.enabled)` — effectively `false` when the editor doesn't set it. But `dependencies/dashboardapi/dashboardapiConfig.json` declares the default as `true`. The editor menu (`loggerMenu`) bridges these via the standard EVOLV logger pattern, but the divergence is worth confirming — logger enabled vs disabled changes whether `Skipping dashboard generation: no template …` warns appear at all.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Confirm in full review which side wins by default for a freshly-dropped node. Flagged.
|
||||||
|
|
||||||
|
### `bucket` resolution priority is global-then-per-position
|
||||||
|
|
||||||
|
`buildDashboard` reads `this.config.defaultBucket || this.config.bucketMap[position] || defaultBucketForPosition(position)`. The global override fires **before** the per-position map. If you want per-position buckets to win over a global default, the current code doesn't do that — you'd need to leave `defaultBucket` empty and rely solely on `bucketMap` + the position fallback.
|
||||||
|
|
||||||
|
Open question whether the "global beats per-position" priority is the intended semantics. Flagged.
|
||||||
|
|
||||||
|
### No InfluxDB bucket validation
|
||||||
|
|
||||||
|
The bucket name is templated into the Grafana dashboard JSON without any check that the bucket exists in InfluxDB. A typo produces a dashboard that renders panels saying "no data" with no upstream warning. Tracked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions (tracked)
|
||||||
|
|
||||||
|
| Question | Where it lives |
|
||||||
|
|:---|:---|
|
||||||
|
| Should `BaseNodeAdapter` grow a passive / HTTP-only mode (skip-registration + skip-output-stream) so dashboardAPI can extend it? | `.claude/refactor/OPEN_QUESTIONS.md` (2026-05-10) — "dashboardAPI skipped BaseNodeAdapter + BaseDomain" |
|
||||||
|
| Confirm `rotatingMachine` softwareType ↔ `config/machine.json` mapping | Internal — flag during full review |
|
||||||
|
| Bucket priority: should per-position `bucketMap` beat global `defaultBucket`? | Internal |
|
||||||
|
| Should dashboardAPI emit a Port-2 status / health pulse so other EVOLV nodes can detect it? | Internal |
|
||||||
|
| Should `child.register` aliases include older topic names (e.g. `RegisterChild`, `register-child`) for legacy compat? | Internal |
|
||||||
|
| Add an explicit `child.unregister` / `dashboard.delete` topic to remove orphaned Grafana dashboards | Internal |
|
||||||
|
| Provide a programmatic way to bulk-regenerate all dashboards for an existing deployment (e.g. `cmd.regenerate-all`) | Internal |
|
||||||
|
| Retry / DLQ for failed Grafana upserts | TBD |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration notes
|
||||||
|
|
||||||
|
### From the `registerChild` alias
|
||||||
|
|
||||||
|
The canonical topic since 2026-Q1 is `child.register`. The `registerChild` alias still works but logs a one-time deprecation warning on first use. Migrate callers when convenient:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- msg.topic = 'registerChild';
|
||||||
|
+ msg.topic = 'child.register';
|
||||||
|
```
|
||||||
|
|
||||||
|
Both topics accept identical payloads.
|
||||||
|
|
||||||
|
### From bare-string node-id payloads
|
||||||
|
|
||||||
|
The handler resolves bare-string payloads via `RED.nodes.getNode(id) → node._flow.getNode(id) → null`. This works at runtime but is brittle for tests and for flows where the emitter and dashboardAPI live on different `_flow` instances. Prefer the inline `{source: {config: {...}}}` or `{config: {...}}` shapes for tests and for any flow that imports both sides as JSON (no `RED.nodes` registry at compile time).
|
||||||
|
|
||||||
|
### From hand-curated Grafana dashboards
|
||||||
|
|
||||||
|
If you're moving from hand-curated dashboards to dashboardAPI-generated ones:
|
||||||
|
|
||||||
|
1. Export your existing dashboard JSON from Grafana.
|
||||||
|
2. Replace the templating-var values for `measurement` and `bucket` with placeholders.
|
||||||
|
3. Save as `nodes/dashboardAPI/config/<softwareType>.json`.
|
||||||
|
4. The next `child.register` for that softwareType will upsert (overwrite) the existing dashboard, preserving the UID if you set it to match `stableUid(softwareType:nodeId)`.
|
||||||
|
|
||||||
|
If you want to **preserve the UID** of an existing hand-curated dashboard, compute `sha1(softwareType:nodeId).slice(0, 12)` and check it matches your existing UID. If not, either rename the node id, or accept that the first upsert will create a new dashboard alongside the old one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Topic + payload resolution + envelope shape |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Code map, lifecycle, "no BaseNodeAdapter" rationale |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes + working wiring patterns |
|
||||||
|
| [EVOLV — Open Questions](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/OPEN_QUESTIONS.md) | Cross-node open questions and decisions log |
|
||||||
20
wiki/_Sidebar.md
Normal file
20
wiki/_Sidebar.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
### dashboardAPI
|
||||||
|
|
||||||
|
- [Home](Home)
|
||||||
|
|
||||||
|
**Reference**
|
||||||
|
|
||||||
|
- [Contracts](Reference-Contracts)
|
||||||
|
- [Architecture](Reference-Architecture)
|
||||||
|
- [Examples](Reference-Examples)
|
||||||
|
- [Limitations](Reference-Limitations)
|
||||||
|
|
||||||
|
**Related**
|
||||||
|
|
||||||
|
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
|
||||||
|
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home)
|
||||||
|
- [pumpingStation wiki](https://gitea.wbd-rd.nl/RnD/pumpingStation/wiki/Home)
|
||||||
|
- [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki/Home)
|
||||||
|
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
|
||||||
|
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
|
||||||
|
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)
|
||||||
18
wiki/_partial-datamodel.md.template
Normal file
18
wiki/_partial-datamodel.md.template
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
No domain output. dashboardAPI emits **HTTP request envelopes on Port 0**, shaped for a downstream `http request` node:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
topic: 'create',
|
||||||
|
url: 'http://<grafana>:<port>/api/dashboards/db',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer …' // only when bearerToken is set
|
||||||
|
},
|
||||||
|
payload: { dashboard: {…}, folderId: 0, overwrite: true },
|
||||||
|
meta: { nodeId, softwareType, uid, title }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Port 1 (InfluxDB telemetry) and Port 2 (registration / control plumbing) are unused — dashboardAPI has no measurements and does not register with a parent.
|
||||||
Reference in New Issue
Block a user