12 Commits

Author SHA1 Message Date
8964b0b638 feat(dashboardapi): MGC template polish — group-level only + dashed bounds (#40)
- config/machineGroup.json: every non-row panel now annotated with
  meta.emittedFields (mode, scaling, abs/relDistFromPeak, flow.total/group,
  power.total/group). Per-pump fields (ctrl, state, runtime, pressure,
  temperature) deliberately absent — those live on rotatingMachine children
  per #39's no-data-duplication contract.
- Timeseries panels gain byRegexp dashed-bounds overrides for .min$/.max$
  (same pattern as #38).
- test/basic/slice40-mgc-template.basic.test.js: 4 cases — no per-pump
  fields leak in, every non-row annotated, dashed overrides present on TS,
  composer dedup applies when a child claims an MGC-level field.

Closes #40
2026-05-26 18:03:28 +02:00
a76f22281e feat(dashboardapi): no-data-duplication rule for parent dashboards (#39)
When generateDashboardsForGraph builds a root dashboard for a parent (e.g.
pumpingStation) and a set of child dashboards (e.g. measurements), it now
removes any non-row panel from the root whose meta.emittedFields are fully
covered by panels declared in any child dashboard. Result: the parent
shows only metrics its children don't already plot, eliminating redundant
rendering of the same series in two dashboards.

- config/pumpingStation.json: 11 non-row panels annotated with
  meta.emittedFields (Direction, Time Left, Flow Source, Fill %, Level (x2),
  Volume, Net Flow Rate, Inflow+Outflow, Heights, Volume Limits).
- src/specificClass.js: generateDashboardsForGraph runs the parent-panel
  filter after composing children; row panels always kept; panels without
  emittedFields declaration always kept (no silent removal).
- test/basic/slice39-no-duplication.basic.test.js: 4 cases — annotation
  presence, child-covered removal, no-overlap preservation, row preservation.

Closes #39
2026-05-26 18:01:58 +02:00
e5099de986 feat(dashboardapi): dashed .min/.max overrides on rotatingMachine panels (#38)
Applies the byRegexp(\\.min$ | \\.max$) → custom.lineStyle dashed pattern to
all 4 timeseries panels in config/machine.json — pattern confirmed via S2
spike (#33). Forward-compatible: nodes that don't yet emit .min/.max fields
see no change in rendering (regex won't match).

- config/machine.json: 4 timeseries panels gain byRegexp overrides for both
  .min$ and .max$, dashed [10,10], orange (min) / red (max).
- test/basic/slice38-dashed-bounds.basic.test.js: 2 cases (presence per ts
  panel, anchor-to-end forward compatibility).

Companion-field emission helper (generalFunctions.outputUtils — produces
<field>, <field>.min, <field>.max from a bounds-aware source) is a
generalFunctions submodule change and lands in a follow-up PR — out of
scope for this dashboardAPI-only slice.

Closes #38
2026-05-26 18:00:40 +02:00
8639b02e6a feat(dashboardapi): emittedFields metadata for parent-panel dedup (#37)
Adds per-panel `meta.emittedFields` to machine.json (rotatingMachine) and
machineGroup.json (MGC) templates. Each non-row panel declares the Influx
field paths it visualizes, so a parent template's composer can filter out
panels already covered by its children (#39 no-data-duplication rule).

- config/machine.json: 13 non-row panels annotated.
- config/machineGroup.json: panels annotated.
- src/specificClass.js: collectEmittedFields(dashboard) helper.
- test/basic/slice37-emitted-fields.basic.test.js: 4 cases (template loads
  with annotations, aggregation, missing-meta graceful, null input).

PRD F-6 panel set audit: machine.json already covers all the PRD-required
panels (State/Mode/Ctrl%/Runtime/NCog%/Flow/Efficiency/Pressure/Temperature/
Diagnostics) — substantially more than asked. No new panels added.

PRD F-7 predicted-vs-measured side-by-side: deferred. Current architecture
is "1 dashboard per node" (each child gets its own dashboard, cross-linked
from the parent), not "1 dashboard with N composed panels." Side-by-side
rendering of predicted (rotatingMachine dashboard) + measured (measurement
child dashboard) lives naturally as drill-down navigation today. Refactor
to a single-dashboard composition model would be substantial — flagged in
the issue comment for v2 if the drill-down UX proves insufficient.

Closes #37
2026-05-26 17:59:37 +02:00
aac71eb129 feat(dashboardapi): diff-skip regen via flows:started predicate (#36)
Subscribes to Node-RED's flows:started runtime event, caches the {diff}
payload on the dashboardAPI source, and short-circuits the child.register
handler when none of {dashboardAPI id, child id, grandchild ids} appears in
diff.added/changed/removed/rewired. Predicate verified by S1 spike (#32).

- src/nodeClass.js: _attachLifecycleHook subscribes, _attachCloseHandler
  cleans up. No-op when RED.events isn't available (unit-test friendly).
- src/specificClass.js: subtreeChanged() predicate + subtreeIdsFor() helper.
- src/commands/handlers.js: registerChild consults predicate before composing;
  logs {event:'regen-skipped', outcome:'no-diff'} when skipping.
- test/basic/slice36-diff-predicate.basic.test.js: 8 cases — null/empty diff,
  affected/unaffected ids, tab-id over-triggering avoidance, grandchild
  inclusion, no-grandchild case.

Cold start (no cached diff yet) always regenerates — safe default. Edge case
documented in #32: when a brand-new child is wired to a registered parent,
the new child's id is in diff.added but not yet in registeredChildren when
flows:started fires. Mitigation (b) per spike findings: one-deploy race
accepted for R&D — next deploy picks up the new registration.

Closes #36
2026-05-26 17:57:34 +02:00
bdf87ffd67 test(dashboardapi): perf + uid-uniqueness for multi-child composition (#35)
Architectural note: existing composition is "1 dashboardAPI → root dashboard
+ 1 per child", not "1 dashboardAPI → 1 dashboard with N panels" as the PRD
assumed. Each generated dashboard is laid out at template-authoring time
(explicit gridPos per panel inside config/<softwareType>.json); the composer's
job is to substitute per-instance templating variables and assemble the
cross-link list. So the PRD's "non-overlapping gridPos for N panels" lands as:

- perf: 50 children compose in <500ms (PRD N-1).
- uid-uniqueness: stableUid keyed on softwareType:nodeId never collides.
- byte-identical idempotency (PRD N-2): two consecutive compositions match.
- root links: one link per registered child.

No production code change — this slice just adds the perf/uniqueness/idempotency
guarantees as explicit tests so we can't regress.

Closes #35
2026-05-26 17:55:39 +02:00
7fdab73ba0 feat(dashboardapi): walking skeleton for graph-aware Grafana generator (#34)
Encrypts the Grafana bearer token via Node-RED credentials block instead of
plain config (F-11). Adds folderUid config field threaded through to the
buildUpsertRequest payload (F-8, resolves PRD O-5). Migration path: legacy
plain bearerToken still loads, with one-time warn() prompting user to re-save.

Composition + URL + headers + per-instance UID were already in place; only
the credentials + folderUid + tests are new.

- dashboardAPI.html: bearerToken moved to credentials block; folderUid added.
- dashboardAPI.js: registerType options pass credentials descriptor.
- src/nodeClass.js: read token from node.credentials; legacy fallback warns.
- src/specificClass.js: buildUpsertRequest emits folderUid when set.
- src/commands/handlers.js: pass folderUid from config to buildUpsertRequest.
- test/basic/slice34-credentials-and-folder.basic.test.js: 5 new tests.

Diff-based regeneration (F-1) and the explicit flows:started lifecycle hook
land in #36 once the S1 spike predicate is wired. Until then, the existing
child.register message trigger continues to drive composition on every
startup-time child registration.

Closes #34
2026-05-26 17:53:42 +02:00
znetsixe
dac8576cab style: palette swatch → (domain-hue redesign 2026-05-21)
Sidebar swatch now follows function family rather than S88 level, so the
palette is visually identifiable instead of monochromatically blue. Editor-group
rectangles in flow.json still follow S88 — only the registerType color changed.
Full table + rationale: superproject .claude/rules/node-red-flow-layout.md §10.0
and .claude/refactor/OPEN_QUESTIONS.md (2026-05-21 entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:05:59 +02:00
znetsixe
e04c4a1132 fix: rename dashboardapi.{js,html} → dashboardAPI.{js,html}
Aligns the entry-file naming with the folder-name convention from
.claude/rules/node-architecture.md / superproject CLAUDE.md.

NON-BREAKING: the Node-RED type id stays lowercase
(`RED.nodes.registerType('dashboardapi', ...)`) so every deployed flow
that references this node continues to load. Only the file paths
change. Admin endpoints `/dashboardapi/menu.js` and
`/dashboardapi/configData.js` are unaffected — they follow the type
id, not the filename.

Updated:
- package.json `main` + `node-red.nodes` value
- test/basic/structure-module-load.basic.test.js require path
- CLAUDE.md: legacy-drift warning replaced with a note explaining the
  type-id preservation strategy

Same approach recommended for the remaining two legacy renames (mgc,
vgc); the superproject CLAUDE.md drift table now spells that out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:36:56 +02:00
znetsixe
0b857ef444 fix: align logging.enabled default to schema (true)
dashboardapiConfig.json declares `logging.enabled.default: true` but:
- dashboardapi.html defaulted `enableLog: false`
- src/specificClass.js `_buildConfig` used `Boolean(...)` which
  coerced undefined to false, overriding the schema default.

Aligned both to the schema: HTML default = true; _buildConfig now
uses `?? true` so an unspecified config follows the schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:59:19 +02:00
znetsixe
fb5a9ebff8 docs(wiki): regenerate topic-contract AUTOGEN block via wiki-gen
Replaces the agent-written placeholder inside Reference-Contracts.md with
the authoritative table generated from src/commands/index.js. Both the
BEGIN and END markers are normalized to the canonical form used by
`@evolv/wiki-gen`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:11:47 +02:00
znetsixe
a9fc51d6f0 docs(wiki): full 5-page wiki matching the rotatingMachine reference format
Replaces the prior stub/partial wiki with a Home + Reference-{Architecture,
Contracts,Examples,Limitations} + _Sidebar structure. Topic-contract and
data-model sections wrapped in AUTOGEN markers for the future wiki-gen tool.
Source-vs-spec contradictions surfaced and flagged inline (not silently
fixed). Pending-review notes mark sections that need a full node review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:42:14 +02:00
24 changed files with 3603 additions and 465 deletions

View File

@@ -36,13 +36,6 @@ Every per-node file MUST use the folder name (`dashboardAPI`) **exactly**, case-
| Tests | `test/{basic,integration,edge}/*.test.js` |
| Example flows | `examples/*.flow.json` |
> **Legacy naming drift in this repo** — to be renamed when the file is next touched:
>
> | Path | Currently | Should be |
> |---|---|---|
> | Entry file | `dashboardapi.js` | `dashboardAPI.js` |
> | Editor HTML | `dashboardapi.html` | `dashboardAPI.html` |
>
> Renames require updating: the file itself, `package.json#node-red.nodes`, any `require()` / `import` paths, and superproject submodule references in one commit.
> **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.

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,10 @@
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
@@ -17,91 +20,503 @@
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Status", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "purple", "value": null }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 },
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"title": "Status",
"type": "row"
},
{
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "purple",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 1
},
"id": 2,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"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==\"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 |> last()",
"refId": "A"
}
],
"title": "Mode",
"type": "stat"
"type": "stat",
"meta": {
"emittedFields": [
"mode"
]
}
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 },
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "blue",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 1
},
"id": 3,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"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==\"scaling\")\n |> last()", "refId": "A" }
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"scaling\")\n |> last()",
"refId": "A"
}
],
"title": "Scaling",
"type": "stat"
"type": "stat",
"meta": {
"emittedFields": [
"scaling"
]
}
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 5 }, { "color": "red", "value": 15 }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 },
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 5
},
{
"color": "red",
"value": 15
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 12,
"y": 1
},
"id": 4,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value",
"graphMode": "area"
},
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"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 |> last()",
"refId": "A"
}
],
"title": "Abs Dist Peak",
"type": "stat"
"type": "stat",
"meta": {
"emittedFields": [
"absDistFromPeak"
]
}
},
{
"datasource": { "type": "influxdb", "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 },
"datasource": {
"type": "influxdb",
"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,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value",
"graphMode": "area"
},
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"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 |> last()",
"refId": "A"
}
],
"title": "Rel Dist Peak",
"type": "stat"
"type": "stat",
"meta": {
"emittedFields": [
"relDistFromPeak"
]
}
},
{ "gridPos": { "h": 1, "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": [] },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 },
"gridPos": {
"h": 1,
"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,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /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 |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Total Flow",
"type": "timeseries"
"type": "timeseries",
"meta": {
"emittedFields": [
"flow.total",
"flow.group"
]
}
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 },
"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": 12,
"y": 6
},
"id": 8,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /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 |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Total Power",
"type": "timeseries"
"type": "timeseries",
"meta": {
"emittedFields": [
"power.total",
"power.group"
]
}
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "machineGroup", "template"],
"tags": [
"EVOLV",
"machineGroup",
"template"
],
"templating": {
"list": [
{ "name": "dbase", "type": "custom", "label": "dbase", "query": "cdzg44tv250jkd", "current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false }, "options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }], "hide": 2 },
{ "name": "measurement", "type": "custom", "query": "template", "current": { "text": "template", "value": "template", "selected": false }, "options": [{ "text": "template", "value": "template", "selected": true }] },
{ "name": "bucket", "type": "custom", "query": "lvl2", "current": { "text": "lvl2", "value": "lvl2", "selected": false }, "options": [{ "text": "lvl2", "value": "lvl2", "selected": true }] }
{
"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
}
]
},
"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": "",
"title": "template",
"uid": null,

View File

@@ -3,7 +3,10 @@
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
@@ -17,153 +20,680 @@
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Status", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 5, "x": 0, "y": 1 },
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"title": "Status",
"type": "row"
},
{
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "blue",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 5,
"x": 0,
"y": 1
},
"id": 2,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value",
"graphMode": "none"
},
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"direction\")\n |> last()", "refId": "A" }
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"direction\")\n |> last()",
"refId": "A"
}
],
"title": "Direction",
"type": "stat"
"type": "stat",
"meta": {
"emittedFields": [
"direction"
]
}
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 300 }, { "color": "red", "value": 600 }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 5, "x": 5, "y": 1 },
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"unit": "s",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 300
},
{
"color": "red",
"value": 600
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 5,
"x": 5,
"y": 1
},
"id": 3,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value",
"graphMode": "area"
},
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"timeleft\")\n |> last()", "refId": "A" }
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"timeleft\")\n |> last()",
"refId": "A"
}
],
"title": "Time Left",
"type": "stat"
"type": "stat",
"meta": {
"emittedFields": [
"timeLeft"
]
}
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "purple", "value": null }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 4, "x": 10, "y": 1 },
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "purple",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 4,
"x": 10,
"y": 1
},
"id": 4,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value",
"graphMode": "none"
},
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"flowSource\")\n |> last()", "refId": "A" }
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"flowSource\")\n |> last()",
"refId": "A"
}
],
"title": "Flow Source",
"type": "stat"
"type": "stat",
"meta": {
"emittedFields": [
"flowSource"
]
}
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "min": 0, "max": 100, "unit": "percent", "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "orange", "value": 20 }, { "color": "green", "value": 40 }, { "color": "orange", "value": 80 }, { "color": "red", "value": 95 }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 5, "x": 14, "y": 1 },
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"min": 0,
"max": 100,
"unit": "percent",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "orange",
"value": 20
},
{
"color": "green",
"value": 40
},
{
"color": "orange",
"value": 80
},
{
"color": "red",
"value": 95
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 5,
"x": 14,
"y": 1
},
"id": 5,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "showThresholdLabels": false, "showThresholdMarkers": true },
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"showThresholdLabels": false,
"showThresholdMarkers": true
},
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^volumePercent\\.predicted\\.atequipment/)\n |> last()", "refId": "A" }
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^volumePercent\\.predicted\\.atequipment/)\n |> last()",
"refId": "A"
}
],
"title": "Fill %",
"type": "gauge"
"type": "gauge",
"meta": {
"emittedFields": [
"volumePercent"
]
}
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 5, "x": 19, "y": 1 },
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"unit": "m",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 5,
"x": 19,
"y": 1
},
"id": 6,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value",
"graphMode": "area"
},
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.predicted\\.atequipment/)\n |> last()", "refId": "A" }
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.predicted\\.atequipment/)\n |> last()",
"refId": "A"
}
],
"title": "Level",
"type": "stat"
"type": "stat",
"meta": {
"emittedFields": [
"level"
]
}
},
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, "id": 7, "title": "Basin", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 },
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 5
},
"id": 7,
"title": "Basin",
"type": "row"
},
{
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"unit": "m",
"custom": {
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 6
},
"id": 8,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.(predicted|measured)\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.(predicted|measured)\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Level",
"type": "timeseries"
"type": "timeseries",
"meta": {
"emittedFields": [
"level"
]
}
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m\u00b3", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 },
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"unit": "m\u00b3",
"custom": {
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 6
},
"id": 9,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^volume\\.predicted\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^volume\\.predicted\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Volume",
"type": "timeseries"
"type": "timeseries",
"meta": {
"emittedFields": [
"volume"
]
}
},
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, "id": 10, "title": "Flow", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m\u00b3/h", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 },
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 14
},
"id": 10,
"title": "Flow",
"type": "row"
},
{
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"unit": "m\u00b3/h",
"custom": {
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 15
},
"id": 11,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^netFlowRate\\.predicted\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^netFlowRate\\.predicted\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Net Flow Rate",
"type": "timeseries"
"type": "timeseries",
"meta": {
"emittedFields": [
"flow.net",
"flow"
]
}
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m\u00b3/h", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 },
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"unit": "m\u00b3/h",
"custom": {
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 15
},
"id": 12,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^flow\\.(predicted|measured)\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^flow\\.(predicted|measured)\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Inflow + Outflow",
"type": "timeseries"
"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 },
"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" },
"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" }
{
"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"
"type": "stat",
"meta": {
"emittedFields": [
"heights.min",
"heights.max"
]
}
},
{
"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 },
"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" },
"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" }
{
"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"
"type": "stat",
"meta": {
"emittedFields": [
"volume.min",
"volume.max"
]
}
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "pumpingStation", "template"],
"tags": [
"EVOLV",
"pumpingStation",
"template"
],
"templating": {
"list": [
{ "name": "dbase", "type": "custom", "label": "dbase", "query": "cdzg44tv250jkd", "current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false }, "options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }], "hide": 2 },
{ "name": "measurement", "type": "custom", "query": "template", "current": { "text": "template", "value": "template", "selected": false }, "options": [{ "text": "template", "value": "template", "selected": true }] },
{ "name": "bucket", "type": "custom", "query": "lvl2", "current": { "text": "lvl2", "value": "lvl2", "selected": false }, "options": [{ "text": "lvl2", "value": "lvl2", "selected": true }] }
{
"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
}
]
},
"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": "",
"title": "template",
"uid": null,

View File

@@ -4,18 +4,21 @@
<script>
RED.nodes.registerType('dashboardapi', {
category: 'EVOLV',
color: '#4f8582',
color: '#7A8BA3',
defaults: {
name: { value: '' },
enableLog: { value: false },
enableLog: { value: true },
logLevel: { value: 'info' },
protocol: { value: 'http' },
host: { value: 'localhost' },
port: { value: 3000 },
bearerToken: { value: '' },
folderUid: { value: '' },
defaultBucket: { value: '' },
},
credentials: {
bearerToken: { type: 'password' },
},
inputs: 1,
outputs: 1,
inputLabels: ['Input'],
@@ -44,11 +47,12 @@
window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node);
}
['name', 'protocol', 'host', 'port', 'bearerToken', 'defaultBucket'].forEach((field) => {
['name', 'protocol', 'host', 'port', 'folderUid', 'defaultBucket'].forEach((field) => {
const element = document.getElementById(`node-input-${field}`);
if (!element) return;
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>
@@ -80,7 +84,12 @@
<div class="form-row">
<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-folderUid"><i class="fa fa-folder-open"></i> Grafana Folder UID</label>
<input type="text" id="node-input-folderUid" placeholder="optional — empty = General folder" style="width:70%;" />
</div>
<div class="form-row">

View File

@@ -9,6 +9,10 @@ module.exports = function (RED) {
RED.nodes.registerType(nameOfNode, function (config) {
RED.nodes.createNode(this, config);
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
}, {
credentials: {
bearerToken: { type: 'password' },
},
});
const menuMgr = new MenuManager();

View File

@@ -2,7 +2,7 @@
"name": "dashboardAPI",
"version": "1.0.0",
"description": "EVOLV Grafana dashboard generator (Node-RED node).",
"main": "dashboardapi.js",
"main": "dashboardAPI.js",
"scripts": {
"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",
@@ -22,7 +22,7 @@
},
"node-red": {
"nodes": {
"dashboardapi": "dashboardapi.js"
"dashboardapi": "dashboardAPI.js"
}
}
}

View File

@@ -24,12 +24,33 @@ function resolveChildNode(childId, ctx) {
// 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.
function registerChild(source, msg, ctx) {
const childSource = resolveChildSource(msg.payload, ctx);
if (!childSource?.config) {
throw new Error('Missing or invalid child node');
}
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;
}
const dashboards = source.generateDashboardsForGraph(childSource, {
includeChildren: Boolean(msg.includeChildren ?? true),
});
@@ -48,7 +69,7 @@ function registerChild(source, msg, ctx) {
headers,
payload: source.buildUpsertRequest({
dashboard: dash.dashboard,
folderId: 0,
folderUid: source.config?.grafanaConnector?.folderUid || undefined,
overwrite: true,
}),
meta: {

View File

@@ -26,10 +26,45 @@ class nodeClass {
this._attachInputHandler();
this._attachCloseHandler();
this._attachLifecycleHook();
}
// Subscribe to Node-RED's `flows:started` event to cache the deploy diff so
// the child.register handler can decide whether *this* dashboardAPI's
// subtree was affected. Predicate documented in Gitea issue #32 spike.
_attachLifecycleHook() {
if (!this.RED?.events?.on) return;
this._flowsStartedListener = (payload) => {
const diff = payload?.diff || null;
this.source.lastFlowsStartedDiff = diff;
this.source.lastFlowsStartedAt = Date.now();
if (this.source?.logger?.debug) {
const summary = diff
? Object.fromEntries(
['added', 'changed', 'removed', 'rewired', 'linked', 'flowChanged']
.map((k) => [k, (diff[k] || []).length])
)
: null;
this.source.logger.debug({ event: 'flows:started', type: payload?.type, diff: summary });
}
};
this.RED.events.on('flows:started', this._flowsStartedListener);
}
_buildConfig(uiConfig) {
const cfgMgr = new configManager();
// 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: {
softwareType: this.name.toLowerCase(),
@@ -39,7 +74,8 @@ class nodeClass {
protocol: uiConfig.protocol || 'http',
host: uiConfig.host || 'localhost',
port: Number(uiConfig.port || 3000),
bearerToken: uiConfig.bearerToken || '',
bearerToken,
folderUid: uiConfig.folderUid || '',
},
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',
});
@@ -65,6 +101,10 @@ class nodeClass {
_attachCloseHandler() {
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();
});
}

View File

@@ -55,7 +55,7 @@ class DashboardApi {
general: {
name: config?.general?.name || 'dashboardapi',
logging: {
enabled: Boolean(config?.general?.logging?.enabled),
enabled: config?.general?.logging?.enabled ?? true,
logLevel: config?.general?.logging?.logLevel || 'info',
},
},
@@ -64,6 +64,7 @@ class DashboardApi {
host: config?.grafanaConnector?.host || 'localhost',
port: Number(config?.grafanaConnector?.port || 3000),
bearerToken: config?.grafanaConnector?.bearerToken || '',
folderUid: config?.grafanaConnector?.folderUid || '',
},
defaultBucket: config?.defaultBucket || '',
bucketMap: config?.bucketMap || {},
@@ -104,6 +105,18 @@ class DashboardApi {
return JSON.parse(raw);
}
// 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() {
const { protocol, host, port } = this.config.grafanaConnector;
return `${protocol}://${host}:${port}/api/dashboards/db`;
@@ -144,8 +157,13 @@ class DashboardApi {
return { dashboard, uid, title, softwareType, nodeId, measurementName };
}
buildUpsertRequest({ dashboard, folderId = 0, overwrite = true }) {
return { dashboard, folderId, overwrite };
buildUpsertRequest({ dashboard, folderId, folderUid, overwrite = true }) {
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) {
@@ -162,6 +180,34 @@ class DashboardApi {
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 ids that constitute "this dashboardAPI + this child + its grandchildren"
// for the diff predicate. Pulls grandchildren via the existing extractChildren walk.
subtreeIdsFor(dashboardApiNodeId, childSource) {
const ids = new Set();
if (dashboardApiNodeId) ids.add(dashboardApiNodeId);
const childId = childSource?.config?.general?.id;
if (childId) ids.add(childId);
for (const { childSource: gc } of this.extractChildren(childSource)) {
const gcId = gc?.config?.general?.id;
if (gcId) ids.add(gcId);
}
return ids;
}
generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) {
if (!rootSource?.config) {
this.logger.warn('generateDashboardsForGraph skipped: root source missing config');
@@ -182,6 +228,32 @@ class DashboardApi {
if (childDash) results.push(childDash);
}
// No-data-duplication rule (PRD F-5, #39): remove root panels whose
// emittedFields are fully covered by panels on child dashboards. The
// parent then shows only metrics its children don't already plot,
// avoiding redundant rendering of the same series in two places.
if (children.length > 0 && rootDash.dashboard) {
const childCoveredFields = new Set();
for (const dash of results.slice(1)) {
for (const f of this.collectEmittedFields(dash.dashboard)) childCoveredFields.add(f);
}
const before = rootDash.dashboard.panels.length;
rootDash.dashboard.panels = rootDash.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 !== rootDash.dashboard.panels.length) {
this.logger.debug({
event: 'parent-panels-deduped',
before,
after: rootDash.dashboard.panels.length,
rootTitle: rootDash.title,
});
}
}
// Add links from the root dashboard to children dashboards (when possible)
if (children.length > 0) {
rootDash.dashboard.links = Array.isArray(rootDash.dashboard.links) ? rootDash.dashboard.links : [];

View 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, '');
});

View File

@@ -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');
});

View 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'));
});

View 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);
});

View 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');
});

View 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');
});

View 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');
});

View File

@@ -3,6 +3,6 @@ const assert = require('node:assert/strict');
test('dashboardAPI module load smoke', () => {
assert.doesNotThrow(() => {
require('../../dashboardapi.js');
require('../../dashboardAPI.js');
});
});

View File

@@ -1,13 +1,28 @@
# dashboardAPI
> **Reflects code as of `7b3da23` · regenerated `2026-05-11` via `npm run wiki:all`**
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
![code-ref](https://img.shields.io/badge/code--ref-a6f09d8-blue) ![s88](https://img.shields.io/badge/S88-Utility-dddddd) ![status](https://img.shields.io/badge/status-pending--review-yellow)
## 1. What this node is
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 &mdash; **no measurements, no tick loop, no parent registration**.
**dashboardAPI** is a utility node that converts EVOLV node topology into Grafana dashboards. On each `child.register` event it resolves the child's 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. It has no measurements, no tick loop, no parent registration, and no BaseDomain/BaseNodeAdapter.
> [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
## 2. Position in the platform
---
## At a glance
| Thing | Value |
|:---|:---|
| What it represents | Utility bridge between EVOLV topology and Grafana &mdash; auto-generates dashboards from `child.register` events |
| S88 level | **Utility** &mdash; 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 &mdash; dashboardAPI is a passive sink; it does not register with a parent |
---
## How it fits
```mermaid
flowchart LR
@@ -15,7 +30,8 @@ flowchart LR
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"| grafana[(Grafana<br/>HTTP API)]
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
@@ -24,264 +40,110 @@ flowchart LR
classDef neutral fill:#dddddd,color:#000
```
dashboardAPI has **no S88 level** — it is a utility node (`#dddddd` neutral). Dashed arrows = inbound `child.register` events (fired at deploy time). The solid arrow is the outbound HTTP upsert on Port 0. The Grafana dashboards that result are what FlowFuse / browser clients view.
Dashed arrows = inbound `child.register` events from any EVOLV process node. The solid arrow is the outbound HTTP upsert envelope on Port 0 &mdash; 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`.
## 3. Capability matrix
---
| Capability | Status | Notes |
|---|---|---|
| Accept `child.register` from any EVOLV node | ✅ | Resolves via `RED.nodes.getNode``node._flow.getNode` → inline payload. |
| Emit Grafana dashboard upsert (Port 0) | ✅ | One msg per generated dashboard, shaped for `http request` node. |
| Walk child graph + emit per-child dashboards | ✅ | `msg.includeChildren: true` by default; opt-out per call. |
| Add root → child dashboard `links[]` | ✅ | Each direct child appears as a navigation link on the root dashboard. |
| Template selection by `softwareType` | ✅ | Reads from `config/<softwareType>.json`; case-insensitive fallback. `machineGroupControl``machineGroup.json` alias. |
| Stable dashboard UID across re-deploys | ✅ | SHA-1(`softwareType:nodeId`) first 12 chars — deterministic, idempotent upsert. |
| Bearer-token auth header | ✅ | Set via editor `bearerToken` field; omitted if empty. |
| InfluxDB bucket injection per position | ✅ | `upstream → lvl1`, `downstream → lvl3`, else `lvl2`; overridden by `defaultBucket` or `INFLUXDB_BUCKET` env. |
| Domain output on Port 0 | ❌ | Port 0 carries HTTP request envelopes only, not measurement data. |
| Port 1 telemetry / Port 2 registration | ❌ | Both unused — see Section 8. |
| Status badge / tick loop / FSM | ❌ | Stateless; no periodic emission. |
## Try it &mdash; 3-minute demo
## 4. Code map
Import the basic example flow, deploy, and watch a `child.register` payload turn into a Grafana dashboard upsert request.
```mermaid
flowchart TB
subgraph entry["dashboardapi.js — entry (Node-RED registration)"]
e["RED.nodes.registerType('dashboardapi')<br/>admin: GET /dashboardapi/menu.js<br/>admin: GET /dashboardapi/configData.js"]
end
subgraph nodeRED["src/nodeClass.js — passive adapter"]
nc["_buildConfig(uiConfig)<br/>createRegistry(commands)<br/>_attachInputHandler() → dispatch<br/>NO BaseNodeAdapter"]
end
subgraph domain["src/specificClass.js — DashboardApi service"]
sc["buildDashboard({ nodeConfig, positionVsParent })<br/>generateDashboardsForGraph(rootSource)<br/>extractChildren(nodeSource)<br/>grafanaUpsertUrl()<br/>loadTemplate(softwareType)"]
end
subgraph cmd["src/commands/"]
h["index.js — child.register + registerChild alias<br/>handlers.js — resolveChildSource + registerChild"]
end
subgraph tpl["config/ — Grafana JSON templates"]
t["aeration | dashboardapi | machine<br/>machineGroup | measurement | monster<br/>pumpingStation | reactor | settler<br/>valve | valveGroupControl"]
end
e --> nodeRED
nodeRED --> domain
nodeRED --> cmd
domain --> tpl
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/dashboardAPI/examples/basic.flow.json \
http://localhost:1880/flow
```
| Module | Owns | Read first if you're changing… |
|---|---|---|
| `dashboardapi.js` | Node-RED registration, admin HTTP endpoints | Adding editor endpoints, node category. |
| `src/nodeClass.js` | Input wiring, command dispatch, config build | Topic dispatching, config key mapping. |
| `src/specificClass.js` | Template loading, dashboard composition, graph walk | UID stability, links, bucket injection. |
| `src/commands/index.js` | Command registry definition | Adding or renaming inbound topics. |
| `src/commands/handlers.js` | `child.register` handler logic | Payload resolution, emit loop. |
| `config/` | Per-softwareType Grafana JSON templates | Adding support for new EVOLV node types. |
What to click after deploy:
dashboardAPI deliberately does NOT use the `concerns/` module pattern — its logic surface is too narrow. See `CONTRACT.md → "Why no BaseNodeAdapter / BaseDomain"` for the full rationale.
1. Open the inject node (`basic trigger`) and edit the payload to a `{source: {config: {...}}}` shape &mdash; see [Reference &mdash; 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.
## 5. Topic contract
> [!IMPORTANT]
> **GIF needed.** Demo recording of the inject &rarr; Port-0 envelope &rarr; Grafana dashboard upsert path. Save as `wiki/_partial-gifs/dashboardAPI/01-basic-demo.gif`, target &le; 1&nbsp;MB after `gifsicle -O3 --lossy=80`.
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
> [!WARNING]
> The shipped `basic.flow.json` / `integration.flow.json` / `edge.flow.json` are stubs &mdash; 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 &mdash; Example flow stubs](Reference-Limitations#example-flow-stubs).
<!-- BEGIN AUTOGEN: topic-contract -->
---
| Canonical topic | Aliases | Payload | Unit | Effect |
|---|---|---|---|---|
| `child.register` | `registerChild` | `any` | — | Parent/child plumbing — registers or unregisters a child node. |
## The one thing you'll send
<!-- END AUTOGEN: topic-contract -->
| 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` &rarr; `node._flow.getNode` &rarr; inline payload), calls `source.generateDashboardsForGraph(child, {includeChildren})`, then emits one `topic: 'create'` HTTP-upsert message on Port 0 per generated dashboard. |
The legacy `registerChild` alias logs a one-time deprecation warning on first use. The payload can be a string (child node id), `{ source: {...} }`, or `{ config: {...} }`; `msg.includeChildren` (default `true`) controls graph-walk depth.
That's it. There is no `set.*`, no `cmd.*`, no `query.*` &mdash; the registry has a single canonical topic (alias-with-deprecation). The legacy `registerChild` alias logs a one-time deprecation warning on first use.
There is **no HTTP endpoint contract** for dashboardAPI — it is a Node-RED input node only. The outbound HTTP call shape is documented in Section 8.
---
## 6. Child registration
## What you'll see come out
dashboardAPI does **not** maintain a child registry of its own. Every inbound `child.register` triggers a one-shot resolution + dashboard emission. No state is held between calls.
Sample Port 0 message after a `child.register` for a `pumpingStation` node with two direct children:
```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 → inline"]
resolve --> walk["generateDashboardsForGraph(childSource)<br/>(walks direct children if includeChildren=true)"]
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
```
| Inbound softwareType | Filter | Side effect |
|---|---|---|
| any | child has `functionality.softwareType` | Loads `config/<softwareType>.json`; emits one upsert msg per dashboard in the graph walk. |
| (template missing) | no matching `config/*.json` | Warns at `warn` level and skips that dashboard. No error thrown. |
## 7. Lifecycle — what one event does
```mermaid
sequenceDiagram
participant emitter as any EVOLV node
participant dash as dashboardAPI (nodeClass)
participant api as DashboardApi (specificClass)
participant out as Port-0 output
participant grafana as Grafana HTTP API
emitter->>dash: child.register {source / config / id}
dash->>dash: commandRegistry.dispatch → handlers.registerChild
dash->>dash: resolveChildSource(payload, ctx)
dash->>api: generateDashboardsForGraph(childSource, {includeChildren})
api->>api: buildDashboard({ nodeConfig, positionVsParent })
api->>api: loadTemplate(softwareType) from config/
api->>api: stableUid = sha1(softwareType:nodeId).slice(0,12)
api->>api: updateTemplatingVar(measurement, bucket)
api->>api: extractChildren → build child dashboards
api->>api: add links[] on root dashboard
api-->>dash: [{dashboard, uid, title, softwareType, nodeId}, ...]
loop per dashboard in results
dash->>out: msg{topic:'create', url, method, headers, payload, meta}
out->>grafana: POST /api/dashboards/db
end
```
One inbound event yields N outbound HTTP messages (N = 1 + direct child count when `includeChildren=true`).
## 8. Data model — output shape
> **dashboardAPI has no domain output.** It does not extend `BaseDomain` and does not implement `getOutput()`. The `wiki:datamodel` script falls back to the hand-curated template below.
<!-- BEGIN AUTOGEN: data-model -->
No domain output. dashboardAPI emits **HTTP request envelopes on Port 0**, shaped for a downstream `http request` node:
```js
```json
{
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
"topic": "create",
"url": "http://grafana:3000/api/dashboards/db",
"method": "POST",
"headers": {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Bearer eyJ..."
},
payload: { dashboard: {}, folderId: 0, overwrite: true },
meta: { nodeId, softwareType, uid, title }
"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"
}
}
```
Port 1 (InfluxDB telemetry) and Port 2 (registration / control plumbing) are unused — dashboardAPI has no measurements and does not register with a parent.
| Field | Meaning |
|:---|:---|
| `topic` | Always `'create'` &mdash; 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)` &mdash; stable across re-deploys. |
| `meta.*` | Correlation fields for the downstream consumer (nodeId, softwareType, uid, title). |
<!-- END AUTOGEN: data-model -->
Inbound `msg` fields propagate via spread (`{...msg, ...envelope}`) so any caller-supplied correlation / trace fields survive.
**`meta` fields:**
> Port 1 (InfluxDB telemetry) and Port 2 (registration / control plumbing) are **unused** &mdash; dashboardAPI has no measurements and does not register with a parent. See [Reference &mdash; Architecture](Reference-Architecture#output-ports).
| Field | Type | Value |
|---|---|---|
| `nodeId` | string | `config.general.id` or `config.general.name` |
| `softwareType` | string | `config.functionality.softwareType` |
| `uid` | string | SHA-1(`softwareType:nodeId`) first 12 chars — stable across re-deploys |
| `title` | string | `config.general.name` |
---
Inbound `msg` fields propagate via spread (`{...msg, ...envelope}`) — caller-supplied correlation/trace fields survive.
## The new bit &mdash; no BaseNodeAdapter / BaseDomain
See `CONTRACT.md` for the full envelope spec and port definitions.
Most EVOLV nodes extend `BaseNodeAdapter` + `BaseDomain` from `generalFunctions/`. `dashboardAPI` does **not** &mdash; per `OPEN_QUESTIONS.md` (2026-05-10) the decision is to keep a bespoke adapter until `BaseNodeAdapter` grows passive / HTTP-only flags.
## 9. Configuration — editor form ↔ config keys
Reasons:
```mermaid
flowchart TB
subgraph editor["Node-RED editor form (dashboardapi.html)"]
f1[Protocol — select http/https]
f2[Grafana Host — text]
f3[Grafana Port — number]
f4[Bearer Token — password]
f5[InfluxDB Bucket — text]
f6[Enable Log — checkbox]
f7[Log Level — select]
end
subgraph config["Runtime config slice (_buildConfig)"]
c1[grafanaConnector.protocol]
c2[grafanaConnector.host]
c3[grafanaConnector.port]
c4[grafanaConnector.bearerToken]
c5[defaultBucket]
c6[general.logging.enabled]
c7[general.logging.logLevel]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
f6 --> c6
f7 --> c7
```
- No `generalFunctions/src/configs/dashboardapi.json` &mdash; `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 &mdash; `BaseNodeAdapter._emitOutputs()` / `outputUtils.formatMsg` assumes a delta-compressed Port 0 / 1 stream; dashboardAPI emits HTTP-shaped messages instead.
- No registration to a parent &mdash; `BaseNodeAdapter._scheduleRegistration` would emit a spurious `child.register` of its own.
- No status badge / tick / measurements / children of its own.
| 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` | 165535 | `grafanaUpsertUrl()` |
| Bearer Token | `grafanaConnector.bearerToken` | `''` | string (Grafana service account token) | `Authorization: Bearer …` header |
| InfluxDB Bucket | `defaultBucket` | `''` → falls back to `INFLUXDB_BUCKET` env → position default | string | `updateTemplatingVar('bucket', …)` |
| Enable Log | `general.logging.enabled` | `false` | boolean | Logger constructor |
| Log Level | `general.logging.logLevel` | `'info'` | `info` \| `debug` \| `warn` \| `error` | Logger constructor |
dashboardAPI uses the shared `commandRegistry` (canonical-topic naming + alias-with-deprecation) and stops there. See [Reference &mdash; Architecture](Reference-Architecture#why-no-basenodeadapter--basedomain) for the full rationale.
**Position-based bucket fallback** (when `defaultBucket` is empty):
---
| `positionVsParent` | Bucket used |
|---|---|
| `upstream` | `lvl1` |
| `downstream` | `lvl3` |
| any other / absent | `lvl2` |
## Need more?
## 10. State chart
| Page | What you'll find |
|:---|:---|
| [Reference &mdash; Contracts](Reference-Contracts) | Full topic contract, config schema, child resolution rules, template alias table |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, HTTP-endpoint lifecycle, template loader, UID stability, graph walk |
| [Reference &mdash; Examples](Reference-Examples) | Shipped example flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Legacy filename drift, stub flows, missing template handling, open questions |
> **Skipped.** dashboardAPI is stateless — no FSM, no tick loop, no operating states. See template rule: "Skip this section for stateless nodes (`measurement`, `dashboardAPI`)."
## 11. Examples
| Tier | File | What it shows | Status |
|---|---|---|---|
| Basic | `examples/basic.flow.json` | Inject `child.register` payload (inline config) + downstream `http request` → Grafana | ⏳ TBD — stub exists |
| Integration | `examples/integration.flow.json` | Real EVOLV node (e.g. pumpingStation) → child.register → dashboardAPI → Grafana | ⏳ TBD — stub exists |
| Dashboard | _n/a_ | dashboardAPI **generates** Grafana dashboards — no FlowFuse chart tier for this node | — |
**Wiring pattern** (inline-payload basic test):
```json
[
{ "type": "inject", "payload": { "source": { "config": { "functionality": { "softwareType": "measurement" }, "general": { "id": "pump-a-flow", "name": "Pump A flow" } } } }, "topic": "child.register" },
{ "type": "dashboardapi" },
{ "type": "http request", "method": "POST" }
]
```
## 12. Debug recipes
| Symptom | First thing to check | Where to look |
|---|---|---|
| No HTTP message emitted on Port 0 | Did `resolveChildSource` return a non-null source? Check that payload has `.source.config` or `.config` or a valid node id. | Container log for "generateDashboardsForGraph skipped" warning. |
| `Skipping dashboard generation: no template` | `config/<softwareType>.json` missing. | `config/` directory — add a template JSON file for the new node type. |
| `machineGroupControl` produces no dashboard | The alias maps to `machineGroup.json` — verify that file exists in `config/`. | `_templateFileForSoftwareType` in `specificClass.js`. |
| Empty `Authorization` header | `bearerToken` not set in editor form. | Editor → Bearer Token field. |
| Wrong InfluxDB bucket in Grafana template variables | `defaultBucket` config or `INFLUXDB_BUCKET` env overrides the position-based default. | `_buildConfig` in `nodeClass.js` + `defaultBucketForPosition` in `specificClass.js`. |
| Dashboard UID changes between deploys | Node id or `softwareType` changed — UID is `sha1(softwareType:nodeId)`. | `stableUid` in `specificClass.js`. |
| `registerChild` alias warns once | Expected — deprecation warning on first use. Migrate caller to topic `child.register`. | Caller `msg.topic`. |
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors. Use only for live debugging sessions.
## 13. When you would NOT use this node
- **Use dashboardAPI only for auto-generating Grafana dashboards from EVOLV topology.** If you maintain dashboards manually in Grafana, skip it — it will overwrite your customisations on every registration event.
- **Don't use dashboardAPI as a generic Grafana HTTP client.** It only emits dashboard upserts (`POST /api/dashboards/db`). For arbitrary Grafana API calls (annotations, alerts, data sources) use a plain `http request` node.
- **Don't wire tick/measurement data into dashboardAPI.** It fires on `child.register` events (deploy time), not on the measurement tick. Wiring Port-0 data from a rotatingMachine or pumpingStation here is a misuse.
- **Don't expect EVOLV child registration to happen automatically.** dashboardAPI passively receives `child.register`; the emitting node (e.g. pumpingStation) must have its Port 2 wired to dashboardAPI's input. See Section 7.
- **Not a BaseDomain node.** dashboardAPI cannot be used wherever a BaseDomain-capable node is required (e.g. as a registered child of machineGroupControl). See OPEN_QUESTIONS.md (2026-05-10) and Section 14.
## 14. Known limitations / current issues
| # | Issue | Tracked in |
|---|---|---|
| 1 | No domain output — cannot be introspected via the standard `getOutput()` channel. Debugging relies on watching Port 0 HTTP envelopes in a debug node. | `CONTRACT.md → "Why no BaseNodeAdapter / BaseDomain"` |
| 2 | Does not extend `BaseNodeAdapter` / `BaseDomain` — decision deferred pending a passive/HTTP-only mode on `BaseNodeAdapter` (skip-registration + skip-output-stream flags). Until that ships the bespoke adapter shape is correct. | `OPEN_QUESTIONS.md` (2026-05-10) — "dashboardAPI skipped BaseNodeAdapter + BaseDomain". Confirm with team whether to revisit when `BaseNodeAdapter` grows a passive mode. |
| 3 | Template discovery is filename-based. Renaming a node's `softwareType` requires renaming (or aliasing) the template file. The `machineGroupControl → machineGroup.json` mapping is a one-off alias in `_templateFileForSoftwareType`. | `src/specificClass.js → _templateFileForSoftwareType` |
| 4 | No retry / circuit-breaker on the downstream `http request` node — Grafana outages silently drop dashboard upserts. | TBD — no issue filed yet |
| 5 | Tier 1/2 example flows exist as stubs only (`basic.flow.json`, `integration.flow.json`) — not yet validated on a live Node-RED instance. | P9 wiki cleanup follow-up |
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) &middot; [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) &middot; [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)

View File

@@ -0,0 +1,294 @@
# Reference &mdash; Architecture
![code-ref](https://img.shields.io/badge/code--ref-a6f09d8-blue)
> [!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 &mdash; 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` &mdash; 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 &mdash; 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 &mdash; 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 &rarr; use `payload.source` directly (inline shape A).
- Else if `payload.config` exists &rarr; wrap as `{ config: payload.config }` (inline shape B).
- Else if `typeof payload === 'string'` &rarr; treat as a node id and resolve via `RED.nodes.getNode(id)` &rarr; fall back to `ctx.node._flow.getNode(id)`.
2. **Throw** `Missing or invalid child node` if neither path yields a `.config` &mdash; 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` &mdash; 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` &mdash; 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 &harr; template mapping (esp. `machine.json` vs the lowercase `rotatingmachine` softwareType emitted by `rotatingMachine`'s `functionality.softwareType`) needs verification during the full review &mdash; flagged.
### UID stability
`stableUid(input) = sha1(input).slice(0, 12)` &mdash; 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` &rarr; `config.bucketMap[position]` &rarr; the table above. `INFLUXDB_BUCKET` env is read in `_buildConfig` and lands in `config.defaultBucket`.
### Root &rarr; child links
When `includeChildren=true` and the root has &ge; 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 &mdash; clicking a link keeps the time range and templating selections.
---
## Lifecycle &mdash; 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 &mdash; 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. | &mdash; |
| 2 (registration / control) | **Unused.** dashboardAPI is a sink for `child.register`, not a source. | &mdash; |
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 &mdash; 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` &rarr; `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 &rarr; lvl1/lvl2/lvl3) | `src/specificClass.js` `defaultBucketForPosition` |
| Editor form &harr; 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` &mdash; copy the closest existing one and adjust |
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + template alias map |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Filename drift, stub flows, open questions |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |
| [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout (dashboardAPI is an exception &mdash; Port 0 carries HTTP envelopes) |

243
wiki/Reference-Contracts.md Normal file
View File

@@ -0,0 +1,243 @@
# Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-a6f09d8-blue)
> [!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 &mdash; 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` &mdash; use directly | `handlers.js` `resolveChildSource` line 6 |
| `{config: {...}}` | `{config: payload.config}` &mdash; wrap minimally | `handlers.js` `resolveChildSource` line 7 |
| `"<node-id>"` (bare string) | `RED.nodes.getNode(id).source` &rarr; fallback `node._flow.getNode(id).source` | `handlers.js` `resolveChildNode` |
| anything else | `null` &rarr; 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 &mdash; Port-0 envelope
<!-- BEGIN AUTOGEN: data-model — populate via wiki-gen tool (TODO) -->
dashboardAPI **has no domain output** &mdash; 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** &mdash; 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'` | &mdash; |
| `headers.Accept` | string | constant | `application/json` |
| `headers.Content-Type` | string | constant | `application/json` |
| `headers.Authorization` | string &#124; 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 &mdash; same `(softwareType, nodeId)` &rarr; 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 &mdash; 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** &mdash; `loadTemplate(softwareType)` from `config/<softwareType>.json` (case-insensitive fallback, `machineGroupControl &rarr; machineGroup.json` alias). Missing template &rarr; logs `warn` and returns `null` (the dashboard is skipped from the output).
2. **UID stamp** &mdash; `dashboard.uid = stableUid(softwareType:nodeId)`.
3. **Title stamp** &mdash; `dashboard.title = config.general.name || nodeId`.
4. **Tags merge** &mdash; existing `template.tags` + `['EVOLV', softwareType, positionVsParent]` (deduplicated, empty values filtered).
5. **Templating var fill** &mdash; `dashboard.templating.list[]` entries named `measurement` and `bucket` are mutated in place:
- `measurement` &larr; `${softwareType}_${nodeId}` (used as InfluxDB measurement name in panel queries).
- `bucket` &larr; resolved bucket (see [Bucket resolution](#bucket-resolution) below).
6. **Links append** (root dashboard only, when `includeChildren=true` and `children.length > 0`) &mdash; 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 &mdash; `upstream &rarr; lvl1`, `downstream &rarr; lvl3`, else `lvl2` |
> [!NOTE]
> Priorities 1 and 2 read order from `specificClass.js` `buildDashboard`. Verify against the editor's intended semantics during full review &mdash; "global override beats per-position map" is the current behaviour. Flagged.
---
## Configuration schema &mdash; 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** &mdash; 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&ndash;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` | `''` &rarr; falls back to `process.env.INFLUXDB_BUCKET` &rarr; position default | Set in `_buildConfig`; consumed by `buildDashboard` templating fill. |
| (no editor field) | `bucketMap` | `{}` | Programmatic only &mdash; 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`) &mdash; confirm during full review that the editor menu correctly maps `enableLog` &rarr; `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` | &mdash; |
| `dashboardapi` | `dashboardapi.json` | Self-template (when a dashboardAPI registers as a child of another dashboardAPI &mdash; unusual). |
| `machine` (or `rotatingmachine`) | `machine.json` | softwareType to verify in full review &mdash; flagged. |
| `machineGroupControl` | `machineGroup.json` | Via one-off alias. |
| `measurement` | `measurement.json` | &mdash; |
| `monster` | `monster.json` | &mdash; |
| `pumpingStation` | `pumpingStation.json` | &mdash; |
| `reactor` | `reactor.json` | &mdash; |
| `settler` | `settler.json` | &mdash; |
| `valve` | `valve.json` | &mdash; |
| `valveGroupControl` | `valveGroupControl.json` | &mdash; |
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` &rarr; `child.unregister` lifecycle, no parent &rarr; 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` &mdash; the child source object (must have `.config`).
- `entry.position` (or `child.positionVsParent`) &mdash; 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 &mdash; 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` &rarr; nodeClass sets red status, calls `node.error`. |
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, lifecycle, graph walk |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Filename drift, stub flows, open questions |
| [EVOLV &mdash; Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
| [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 layout (dashboardAPI is an exception &mdash; Port 0 carries HTTP envelopes) |

171
wiki/Reference-Examples.md Normal file
View File

@@ -0,0 +1,171 @@
# Reference &mdash; Examples
![code-ref](https://img.shields.io/badge/code--ref-a6f09d8-blue)
> [!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** &mdash; 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. | &#9203; **Stub** &mdash; 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. | &#9203; **Stub** &mdash; 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. | &#10003; Works as a registry-coverage probe. |
All three are tracked for replacement in the next wiki-cleanup pass &mdash; see [Limitations &mdash; 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 &rarr; Import &rarr; 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 &mdash; 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')` &rarr; 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 &mdash; 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 &mdash; 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 &rarr; Administration &rarr; 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 &rarr; 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 &mdash; add a template JSON, or fix the emitting node's `functionality.softwareType`. |
| `machineGroupControl` produces no dashboard | The alias maps to `machineGroup.json` &mdash; verify that file exists in `config/`. | `_templateFileForSoftwareType` in `src/specificClass.js`. |
| Empty `Authorization` header | `bearerToken` not set in editor form &mdash; the header is omitted entirely when the token is empty, not set to `'Bearer '`. | Editor &rarr; Bearer Token field. |
| Wrong InfluxDB bucket in Grafana template variables | `defaultBucket` config (or `INFLUXDB_BUCKET` env) overrides the position-based default. Priority order: `defaultBucket` &rarr; `bucketMap[position]` &rarr; `defaultBucketForPosition`. | `_buildConfig` in `nodeClass.js` + `defaultBucketForPosition` in `specificClass.js`. |
| Dashboard UID changes between deploys | Node id or `softwareType` changed &mdash; UID is `sha1(softwareType:nodeId).slice(0, 12)`. Stable only if both are stable. | `stableUid` in `specificClass.js`. |
| `registerChild` alias warns once | Expected &mdash; 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&ndash;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 &rarr; Administration &rarr; 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 &rarr; Network &rarr; `menu.js`; check the entry file's logger menu endpoint. |
> Never ship `enableLog: 'debug'` in a demo &mdash; 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 &mdash; that's correct behaviour.
The downstream `http request` node is **optional** for the smoke test &mdash; the dashboardAPI emits regardless of whether anything POSTs the envelope to Grafana.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + payload resolution + envelope shape |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, lifecycle, graph walk |
| [Reference &mdash; Limitations](Reference-Limitations) | Stub flows, filename drift, open questions |
| [EVOLV &mdash; Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where dashboardAPI fits in a larger plant |

View File

@@ -0,0 +1,156 @@
# Reference &mdash; Limitations
![code-ref](https://img.shields.io/badge/code--ref-a6f09d8-blue)
> [!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 &mdash; 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 &mdash; 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 &mdash; `dashboardapi.js` and `dashboardapi.html` &mdash; 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` &rarr; `dashboardAPI.js`
2. `dashboardapi.html` &rarr; `dashboardAPI.html`
3. `package.json#node-red.nodes` &mdash; 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 &mdash; 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) &mdash; 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 &mdash; 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** &mdash; on resolution failure the handler throws and nodeClass sets a red status badge instead |
| 1 | (unused) | &mdash; | &mdash; |
| 2 | (unused) | &mdash; | &mdash; |
The full output-coverage rule applies prospectively; no backfill manifest exists yet. Tracked.
### Template discovery is filename-based
The template lookup is `softwareType` &harr; filename. Renaming a node's `softwareType` (e.g. `rotatingmachine` &rarr; `rotatingMachine`) requires either renaming the template file or adding an alias arm in `_templateFileForSoftwareType`. The `machineGroupControl &rarr; 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` &mdash; 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 &mdash; 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)` &mdash; 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 &mdash; 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 &mdash; 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) &mdash; "dashboardAPI skipped BaseNodeAdapter + BaseDomain" |
| Confirm `rotatingMachine` softwareType &harr; `config/machine.json` mapping | Internal &mdash; 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) &rarr; node._flow.getNode(id) &rarr; 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 &mdash; Contracts](Reference-Contracts) | Topic + payload resolution + envelope shape |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, lifecycle, "no BaseNodeAdapter" rationale |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes + working wiring patterns |
| [EVOLV &mdash; 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
View 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)