13 Commits

Author SHA1 Message Date
znetsixe
8a26e17780 chore(dashboardAPI): Tank Layout fills card vertically too
Canvas frame height 600 → 760 px and tank rectangle height 520 → 680 px
so the visual fills the card aspect (taller than wide). Floor footer
moves to y=702 (was 542) to stay just below the new tank floor.

In-canvas bottom readouts (level / volume / fill mini-stats) removed —
they were redundant with the Status row Level stat, the bar gauge, and
the Level/Volume timeseries, and were getting clipped below the card's
visible area anyway. The basin canvas now shows only basin-structure
information (geometry, zones, thresholds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:12:01 +02:00
znetsixe
3cd749bf37 chore(dashboardAPI): inline basin labels — tank fills card width
Tank visual now fills the Canvas card edge-to-edge instead of leaving
horizontal padding for external name + value label columns. Each
threshold's name and value sit INSIDE the tank near its line ('overflow-
Level  3.22 m', 'highSafety  3.16 m', etc.), right-aligned at the tank's
inner right edge.

Tank rectangle, zone tints, threshold lines, header rim, and footer floor
all widen from left:80 width:200 → left:10 width:380 to fill the frame.
Label colors darkened slightly (e.g. #e54343 → #c92020) to keep contrast
against the semi-transparent zone tint backgrounds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:59:31 +02:00
znetsixe
70151e52ec chore(dashboardAPI): Tank Layout card width matches its visual
Canvas frame logical width: 480 → 400 px (was leaving ~104 px of empty
space on the right inside the card). Panel grid width: 8 → 6 cols so the
card pixel width matches the frame logical width and content fills it
without horizontal padding, instead of letterboxing in the centre.

Bottom readouts repositioned to fit within 400 px (level/volume/fill all
inside the new frame width) and per-field decimal overrides added so unit
formatting doesn't truncate ('100.00 mm' fits in the value label width).

Freed grid cols flow to the Level + Volume timeseries on the right
(w:12 → 14 each, x:12 → 10) so the right half consumes the rest of the
row without a gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:53:08 +02:00
znetsixe
b3972d4a2f chore(dashboardAPI): double basin row height for pumpingStation
Basin row grows from h:10 to h:20. Bar gauge, Canvas, and Level/Volume
timeseries all scale proportionally. Canvas internal frame doubled (480x600)
and tank rectangle stretched (height 240→520) so the canvas content fills
the panel instead of letterboxing in the top half. Bottom readouts moved
from y=280 to y=562 to stay just below the taller tank floor.

Flow row + its panels shifted down by 10 grid rows to make room.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:45:51 +02:00
znetsixe
3529c9f970 feat(dashboardAPI): basin canvas + bar gauge for pumpingStation
Replaces the configuration row's Heights + Volume Limits stat panels and
the radial Fill % gauge with an integrated basin visual that conveys tank
geometry and live water level at a glance.

Configuration row → Basin row:
  - Vertical bar gauge bound to level (m) with min=0/max=basinHeight and
    thresholds at outflow/dryRun/inflow/highSafety/overflow safety levels.
  - Canvas panel with tank outline, zone tints (dead/operating/highSafety/
    spill), threshold lines + named labels, and live numeric readouts for
    each threshold value plus current level/volume/fill at the bottom.
  - Level + Volume timeseries moved next to the basin visual so the row
    reads as basin → trends left-to-right.

Other layout polish:
  - Status row Fill % gauge removed; remaining 4 stats widen to w:6 each.
  - Old "Basin" row header dropped (its panels migrated into the new row).
  - Configuration row renamed to "Basin".

Mechanics:
  - dashboardAPI substitutes mustache {{var}} placeholders in templates at
    JSON.parse time. Per-softwareType var sets live in _templateVarsForNode;
    pumpingStation gets basin geometry + derived safety levels + canvas
    pixel y-positions + min-gap-enforced label positions.
  - Mustache braces stay distinct from Grafana's ${var} dashboard variables.
  - Canvas Flux query pivots heights + predicted level/volume/percent into
    one row with normalized field names so metric-value elements can bind.

No node-side telemetry change: dryRunLevel + highVolumeSafetyLevel already
reach Influx via getOutput() (specificClass.js:248,250) and outputUtils
iterates every key with no filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:32:52 +02:00
znetsixe
90536d631d fix(dashboardAPI): MGC dashboard — drop dead Scaling panel, show group Mode/RelDistPeak
- Remove the "Scaling" stat panel: it queried field 'scaling' that machineGroup
  never emits, so it always rendered "No data".
- The "Mode" and "Rel Dist Peak" panels were stripped by the #39 no-duplication
  rule because child pumps emit fields of the same name ('mode', 'relDistFromPeak').
  But those are the GROUP's own measurement, never a true duplicate of per-pump
  series. Mark them emittedFields:[] (the existing "always keep" convention used by
  the injected pump panels) so the group-level status/metric renders.

Verified live: MGC dashboard now shows Mode "optimalControl", Abs/Rel Dist Peak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:47:57 +02:00
znetsixe
c4f5b68c6a fix(dashboardAPI): clean stat panels — dedup stray-tag series, value-only text, meter units
Three display defects surfaced when rendering the live PS/pump/MGC dashboards:

1. Doubled values everywhere. EVOLV telemetry historically carried stray tags
   (tagcode="undefined"/uuid="null") that newer writes dropped, so InfluxDB holds
   two series per field and last() returned two of everything (e.g. Time Left,
   Runtime, Heights). Add |> group(columns:["_field"]) before last()/aggregateWindow
   in every template query so each field collapses to one value/line regardless of
   tag-set history.

2. fields:"/.*/" also rendered the _time column as a stat value. For single-field
   string panels (Direction, Flow Source, Mode, State, Prediction Quality) append
   |> keep(columns:["_value"]); for mixed string+numeric panels (valve/vgc/monster)
   drop _time/_start/_stop instead.

3. Level/Heights showed "0.12 min" — Grafana unit id "m" means minutes, not meters.
   Change to lengthm; normalize m³->m3, m³/h->m3/h on pumpingStation.

Verified live via headless screenshots: PS, pump, and measurement dashboards now
show single clean values with correct units.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:42:47 +02:00
znetsixe
8bfc67c610 fix(dashboardAPI): show string fields in stat panels (reduceOptions.fields)
Grafana Stat/Gauge panels default the Fields option to "Numeric Fields"
(reduceOptions.fields == ""), so string-valued fields (mode, state,
movementState, direction, flowSource, predictionQuality, running) are excluded
and the panel renders "No data" even though the Flux query (last()) returns the
string correctly.

Set reduceOptions.fields = "/.*/" ("All fields") on every stat panel bound to a
string field across machine, machineGroup, pumpingStation, valve,
valveGroupControl, and monster templates. lastNotNull calc was already correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:09:24 +02:00
znetsixe
5d651b59ef feat(dashboardAPI): resolve Grafana folder by name (fixes stale folderUid 400s)
A pinned folderUid goes stale whenever Grafana is rebuilt — the same-named
folder returns with a fresh uid and every dashboard upsert then 400s
"folder not found", silently dropping all generated dashboards.

Add a folderTitle config field: when set, resolveFolderUid() looks the folder
up by name (GET /api/folders), creates it if absent (POST /api/folders),
caches the uid for the process, and falls back to the configured folderUid on
any failure (never worse than the pinned behavior). The emit handlers
(registerChild/regenerateDashboard/emitDashboardsFor) are now async and await
the resolution. folderUid retained as an explicit override/fallback.

Locked by slice48-folder-resolve-by-name; existing emit tests made async.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:02:38 +02:00
znetsixe
5533293647 feat(dashboardAPI): slice47 MGC pump panel telemetry + tests
- specificClass updates for MGC per-pump panel sources.
- Output manifest + slice47 basic test for the pump-panel outputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:09:29 +02:00
znetsixe
990a8c09ea feat(dashboardapi): recursive subtree discovery + measurement-name/template parity
Generate dashboards for an entire parent-child subtree from a single root
registration (pre-order, cycle/diamond-safe), so wiring only the subtree root
(e.g. pumpingStation) to dashboardAPI yields dashboards for every descendant.

Fix two contract drifts that left generated panels blank against live telemetry:
- _measurement var now mirrors outputUtils.formatMsg (general.name ||
  <softwareType>_<id>); previously it always used the fallback form, so any
  named node's dashboard queried a non-existent series.
- pumpingStation template field keys realigned to emitted telemetry
  (flow.*.{upstream,out,overflow}, netFlowRate.measured, inflowLevel/
  outflowLevel/overflowLevel, maxVolAtOverflow/minVolAt{Inflow,Outflow}).

Adds template alias resolution (softwareType -> shared template file) and
locks parity with slice44/45/46 tests + output manifest. 67/67 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:45:37 +02:00
dc08c85409 docs(dashboardapi): output-coverage manifest + populated/degraded tests (#43)
Per .claude/rules/output-coverage.md every node ships test/_output-manifest.md
enumerating every output across every state. This manifest covers all the
outputs added by slices #34-#42 in this PRD:

- Port 0 upsert message: every key (topic, url, method, headers, payload,
  meta) with type and tested states.
- Port 1: explicit "not used" with rationale.
- Port 2: explicit "not used" with rationale.
- Structured log outputs: 5 events (regen-emitted, regen-skipped,
  manual-regen-requested, parent-panels-deduped, flows:started) with
  fields and corresponding test.
- specificClass return shapes: 6 methods with populated + degraded states.
- Anti-patterns enforced: no payload:null, absent vs null discipline,
  tab id avoidance in predicate.

- test/_output-manifest.md: the manifest.
- test/basic/slice43-output-manifest.basic.test.js: 6 cross-cutting tests
  exercising populated AND degraded states (token absent, folderUid absent,
  template missing, diff-skip, regen logging, manual regen).

Backfill manifests for other nodes tracked in IMPROVEMENTS_BACKLOG.

Closes #43
2026-05-26 18:08:48 +02:00
2b745dfb51 example(dashboardapi): basic.flow.json demos end-to-end Grafana round-trip (#42)
Replaces the placeholder inject→dashboardapi→debug example with the full
chain: inject (simulating a measurement child registration) → dashboardapi
(composes dashboard JSON) → http request (POSTs to Grafana) → debug (shows
the response). Default targets http://grafana:3000 inside the Docker compose
network. Configure bearer token via the encrypted credentials field.

Refs #42
2026-05-26 18:06:54 +02:00
26 changed files with 2057 additions and 771 deletions

View File

@@ -150,7 +150,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"q\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"_results\")", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"q\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"_results\")",
"refId": "A" "refId": "A"
}, },
{ {
@@ -159,7 +159,7 @@
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"hide": false, "hide": false,
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reciruclation\" and r._field == \"q\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"_results\")", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reciruclation\" and r._field == \"q\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"_results\")",
"refId": "B" "refId": "B"
}, },
{ {
@@ -168,7 +168,7 @@
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"hide": false, "hide": false,
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"recircN1\" and r._field == \"q\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"_results\")", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"recircN1\" and r._field == \"q\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"_results\")",
"refId": "C" "refId": "C"
} }
], ],
@@ -282,7 +282,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"reactor1.S10.NH4+|NH3\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"reactor1.S10.NH4+|NH3\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -351,7 +351,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"iFlow\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"iFlow\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -420,7 +420,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"oFlowElement\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"oFlowElement\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -489,7 +489,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"oPLoss\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"oPLoss\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -558,7 +558,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"oOtr\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone1\" and r._field == \"oOtr\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -627,7 +627,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"reactor1.S7.O2\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"reactor1.S7.O2\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -702,7 +702,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"dFactor\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"dFactor\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -777,7 +777,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"sludge\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r._field == \"sludge\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -872,7 +872,7 @@
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"hide": false, "hide": false,
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"reactor2.S10.NH4+|NH3\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"reactor2.S10.NH4+|NH3\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "B" "refId": "B"
} }
], ],
@@ -940,7 +940,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"iFlow\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"iFlow\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -1009,7 +1009,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"oFlowElement\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"oFlowElement\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -1089,7 +1089,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"oPLoss\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"oPLoss\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -1158,7 +1158,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"oOtr\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone2\" and r._field == \"oOtr\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -1227,7 +1227,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"reactor2.S7.O2\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"reactor2.S7.O2\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -1302,7 +1302,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"dFactor\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"dFactor\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -1375,7 +1375,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"sludge\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor2\" and r._field == \"sludge\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -1470,7 +1470,7 @@
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"hide": false, "hide": false,
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"reactor3.S10.NH4+|NH3\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"reactor3.S10.NH4+|NH3\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "C" "refId": "C"
} }
], ],
@@ -1538,7 +1538,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"iFlow\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"iFlow\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -1607,7 +1607,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"oFlowElement\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"oFlowElement\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -1687,7 +1687,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"oPLoss\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"oPLoss\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -1756,7 +1756,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"oOtr\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone3\" and r._field == \"oOtr\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -1825,7 +1825,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"reactor3.S7.O2\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"reactor3.S7.O2\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -1900,7 +1900,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"dFactor\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"dFactor\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -1973,7 +1973,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"sludge\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor3\" and r._field == \"sludge\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -2068,7 +2068,7 @@
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"hide": false, "hide": false,
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NH4+|NH3\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NH4+|NH3\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "D" "refId": "D"
} }
], ],
@@ -2136,7 +2136,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"iFlow\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"iFlow\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -2205,7 +2205,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"oFlowElement\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"oFlowElement\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -2285,7 +2285,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"oPLoss\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"oPLoss\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -2354,7 +2354,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"oOtr\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"zone4\" and r._field == \"oOtr\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -2423,7 +2423,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S7.O2\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S7.O2\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -2498,7 +2498,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"dFactor\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"dFactor\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -2571,7 +2571,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"sludge\" )\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"sludge\" )\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -2681,7 +2681,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"influent.snh\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"influent NH4+|NH3\")", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"influent.snh\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"influent NH4+|NH3\")",
"refId": "A" "refId": "A"
}, },
{ {
@@ -2690,7 +2690,7 @@
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"hide": false, "hide": false,
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NH4+|NH3\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"Reactor1 NH4+|NH3\")", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NH4+|NH3\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"Reactor1 NH4+|NH3\")",
"refId": "B" "refId": "B"
} }
], ],
@@ -2787,7 +2787,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => ( r._measurement == \"reactor1\" or r._measurement == \"reactor2\" or r._measurement == \"reactor3\" or r._measurement == \"reactor4\") and r._field == \"sludge\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> movingAverage(n: 50)", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => ( r._measurement == \"reactor1\" or r._measurement == \"reactor2\" or r._measurement == \"reactor3\" or r._measurement == \"reactor4\") and r._field == \"sludge\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> movingAverage(n: 50)",
"refId": "A" "refId": "A"
} }
], ],
@@ -2869,7 +2869,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"influent.snh\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"influent.snh\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
}, },
{ {
@@ -2878,7 +2878,7 @@
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"hide": false, "hide": false,
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"q\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"q\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "C" "refId": "C"
} }
], ],
@@ -2979,7 +2979,7 @@
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"hide": false, "hide": false,
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NH4+|NH3\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NH4+|NH3\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
}, },
{ {
@@ -2988,7 +2988,7 @@
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"hide": false, "hide": false,
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"q\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> last()", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"q\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "C" "refId": "C"
} }
], ],
@@ -3099,7 +3099,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"influent.sno\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"influent NH4+|NH3\")", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"influent\" and r._field == \"influent.sno\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"influent NH4+|NH3\")",
"refId": "A" "refId": "A"
}, },
{ {
@@ -3108,7 +3108,7 @@
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"hide": false, "hide": false,
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NO3-|NO2-\" )\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"Reactor1 NH4+|NH3\")", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor4\" and r._field == \"reactor4.S10.NO3-|NO2-\" )\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n |> yield(name: \"Reactor1 NH4+|NH3\")",
"refId": "B" "refId": "B"
} }
], ],
@@ -3214,7 +3214,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "cdzg44tv250jkd" "uid": "cdzg44tv250jkd"
}, },
"query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r.group == \"bio\" and r._field =~ /reactor1.S1/)\r\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n ", "query": "from(bucket: \"sim\")\r\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\r\n |> filter(fn: (r) => r._measurement == \"reactor1\" and r.group == \"bio\" and r._field =~ /reactor1.S1/)\r\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)\r\n ",
"refId": "A" "refId": "A"
} }
], ],

View File

@@ -25,7 +25,7 @@
"options": { "legend": { "displayMode": "list", "placement": "bottom" } }, "options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\")\n |> aggregateWindow(every: v.windowPeriod, fn: count, createEmpty: false)", "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\")\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: count, createEmpty: false)",
"refId": "A" "refId": "A"
} }
], ],

View File

@@ -88,14 +88,15 @@
"reduceOptions": { "reduceOptions": {
"calcs": [ "calcs": [
"lastNotNull" "lastNotNull"
] ],
"fields": "/.*/"
}, },
"colorMode": "value", "colorMode": "value",
"graphMode": "none" "graphMode": "none"
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"state\")\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"state\")\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
"refId": "A" "refId": "A"
} }
], ],
@@ -137,14 +138,15 @@
"reduceOptions": { "reduceOptions": {
"calcs": [ "calcs": [
"lastNotNull" "lastNotNull"
] ],
"fields": "/.*/"
}, },
"colorMode": "value", "colorMode": "value",
"graphMode": "none" "graphMode": "none"
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mode\")\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mode\")\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
"refId": "A" "refId": "A"
} }
], ],
@@ -208,7 +210,7 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"ctrl\")\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"ctrl\")\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -258,7 +260,7 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"runtime\")\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"runtime\")\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -322,7 +324,7 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"NCogPercent\")\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"NCogPercent\")\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -429,7 +431,7 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^flow\\.predicted\\.(downstream|atequipment)/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^flow\\.predicted\\.(downstream|atequipment)/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A" "refId": "A"
} }
], ],
@@ -526,7 +528,7 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"cog\" or r._field==\"NCogPercent\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"cog\" or r._field==\"NCogPercent\"))\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A" "refId": "A"
} }
], ],
@@ -635,7 +637,7 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^pressure\\.(predicted|measured)\\.(upstream|downstream)/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^pressure\\.(predicted|measured)\\.(upstream|downstream)/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A" "refId": "A"
} }
], ],
@@ -732,7 +734,7 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^temperature/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^temperature/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A" "refId": "A"
} }
], ],
@@ -793,14 +795,15 @@
"reduceOptions": { "reduceOptions": {
"calcs": [ "calcs": [
"lastNotNull" "lastNotNull"
] ],
"fields": "/.*/"
}, },
"colorMode": "value", "colorMode": "value",
"graphMode": "area" "graphMode": "area"
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"predictionQuality\")\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"predictionQuality\")\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
"refId": "A" "refId": "A"
} }
], ],
@@ -857,7 +860,7 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"predictionConfidence\")\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"predictionConfidence\")\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -914,7 +917,7 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"pressureDriftLevel\")\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"pressureDriftLevel\")\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -971,7 +974,7 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"effDistFromPeak\" or r._field==\"effRelDistFromPeak\"))\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"effDistFromPeak\" or r._field==\"effRelDistFromPeak\"))\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],

View File

@@ -61,72 +61,22 @@
"reduceOptions": { "reduceOptions": {
"calcs": [ "calcs": [
"lastNotNull" "lastNotNull"
] ],
"fields": "/.*/"
}, },
"colorMode": "value", "colorMode": "value",
"graphMode": "none" "graphMode": "none"
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mode\")\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mode\")\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
"refId": "A" "refId": "A"
} }
], ],
"title": "Mode", "title": "Mode",
"type": "stat", "type": "stat",
"meta": { "meta": {
"emittedFields": [ "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
},
"id": 3,
"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"
}
],
"title": "Scaling",
"type": "stat",
"meta": {
"emittedFields": [
"scaling"
]
} }
}, },
{ {
@@ -174,7 +124,7 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"absDistFromPeak\")\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"absDistFromPeak\")\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
@@ -232,16 +182,14 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"relDistFromPeak\")\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"relDistFromPeak\")\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A" "refId": "A"
} }
], ],
"title": "Rel Dist Peak", "title": "Rel Dist Peak",
"type": "stat", "type": "stat",
"meta": { "meta": {
"emittedFields": [ "emittedFields": []
"relDistFromPeak"
]
} }
}, },
{ {
@@ -339,7 +287,7 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /predicted_flow|flow/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /predicted_flow|flow/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A" "refId": "A"
} }
], ],
@@ -436,7 +384,7 @@
}, },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /predicted_power|power/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /predicted_power|power/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A" "refId": "A"
} }
], ],

View File

@@ -25,7 +25,7 @@
"id": 2, "id": 2,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" }, "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mAbs\")\n |> last()", "refId": "A" } { "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mAbs\")\n |> group(columns:[\"_field\"])\n |> last()", "refId": "A" }
], ],
"title": "mAbs (current)", "title": "mAbs (current)",
"type": "stat" "type": "stat"
@@ -37,7 +37,7 @@
"id": 3, "id": 3,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "showThresholdLabels": false, "showThresholdMarkers": true }, "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "showThresholdLabels": false, "showThresholdMarkers": true },
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mPercent\")\n |> last()", "refId": "A" } { "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mPercent\")\n |> group(columns:[\"_field\"])\n |> last()", "refId": "A" }
], ],
"title": "mPercent", "title": "mPercent",
"type": "gauge" "type": "gauge"
@@ -49,7 +49,7 @@
"id": 4, "id": 4,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"mAbs\" or r._field==\"mPercent\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } { "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"mAbs\" or r._field==\"mPercent\"))\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
], ],
"title": "mAbs over Time", "title": "mAbs over Time",
"type": "timeseries" "type": "timeseries"
@@ -62,7 +62,7 @@
"id": 6, "id": 6,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"mAbs\" or r._field==\"totalMinSmooth\" or r._field==\"totalMaxSmooth\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } { "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"mAbs\" or r._field==\"totalMinSmooth\" or r._field==\"totalMaxSmooth\"))\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
], ],
"title": "mAbs + Smooth Bounds", "title": "mAbs + Smooth Bounds",
"type": "timeseries" "type": "timeseries"
@@ -74,7 +74,7 @@
"id": 7, "id": 7,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"totalMinValue\" or r._field==\"totalMaxValue\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } { "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"totalMinValue\" or r._field==\"totalMaxValue\"))\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
], ],
"title": "Absolute Min / Max", "title": "Absolute Min / Max",
"type": "timeseries" "type": "timeseries"

View File

@@ -3,7 +3,10 @@
"list": [ "list": [
{ {
"builtIn": 1, "builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" }, "datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true, "enable": true,
"hide": true, "hide": true,
"iconColor": "rgba(0, 211, 255, 1)", "iconColor": "rgba(0, 211, 255, 1)",
@@ -17,14 +20,42 @@
"id": null, "id": null,
"links": [], "links": [],
"panels": [ "panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Realtime Sampling (Monster)", "type": "row" },
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "gridPos": {
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 1 }, "h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"title": "Realtime Sampling (Monster)",
"type": "row"
},
{
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"gridPos": {
"h": 5,
"w": 8,
"x": 0,
"y": 1
},
"id": 2, "id": 2,
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "/.*/"
},
"colorMode": "value",
"graphMode": "none"
},
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -30d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"running\" or r._field==\"pulse\"))\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -30d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"running\" or r._field==\"pulse\"))\n |> group(columns:[\"_field\"])\n |> last()\n |> drop(columns:[\"_time\",\"_start\",\"_stop\"])",
"refId": "A" "refId": "A"
} }
], ],
@@ -32,14 +63,32 @@
"type": "stat" "type": "stat"
}, },
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "datasource": {
"fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] }, "type": "influxdb",
"gridPos": { "h": 9, "w": 16, "x": 8, "y": 1 }, "uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 16,
"x": 8,
"y": 1
},
"id": 3, "id": 3,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } }, "options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
}
},
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"bucketVol\" or r._field==\"sumPuls\" or r._field==\"q\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"bucketVol\" or r._field==\"sumPuls\" or r._field==\"q\"))\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A" "refId": "A"
} }
], ],
@@ -48,7 +97,11 @@
} }
], ],
"schemaVersion": 39, "schemaVersion": 39,
"tags": ["EVOLV", "monster", "template"], "tags": [
"EVOLV",
"monster",
"template"
],
"templating": { "templating": {
"list": [ "list": [
{ {
@@ -56,30 +109,62 @@
"type": "custom", "type": "custom",
"label": "dbase", "label": "dbase",
"query": "cdzg44tv250jkd", "query": "cdzg44tv250jkd",
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false }, "current": {
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }], "text": "cdzg44tv250jkd",
"value": "cdzg44tv250jkd",
"selected": false
},
"options": [
{
"text": "cdzg44tv250jkd",
"value": "cdzg44tv250jkd",
"selected": true
}
],
"hide": 2 "hide": 2
}, },
{ {
"name": "measurement", "name": "measurement",
"type": "custom", "type": "custom",
"query": "template", "query": "template",
"current": { "text": "template", "value": "template", "selected": false }, "current": {
"options": [{ "text": "template", "value": "template", "selected": true }] "text": "template",
"value": "template",
"selected": false
},
"options": [
{
"text": "template",
"value": "template",
"selected": true
}
]
}, },
{ {
"name": "bucket", "name": "bucket",
"type": "custom", "type": "custom",
"query": "lvl2", "query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false }, "current": {
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }] "text": "lvl2",
"value": "lvl2",
"selected": false
},
"options": [
{
"text": "lvl2",
"value": "lvl2",
"selected": true
}
]
} }
] ]
}, },
"time": { "from": "now-24h", "to": "now" }, "time": {
"from": "now-24h",
"to": "now"
},
"timezone": "", "timezone": "",
"title": "template", "title": "template",
"uid": null, "uid": null,
"version": 1 "version": 1
} }

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@
"id": 2, "id": 2,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" }, "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^S_O/)\n |> last()", "refId": "A" } { "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^S_O/)\n |> group(columns:[\"_field\"])\n |> last()", "refId": "A" }
], ],
"title": "DO (S_O)", "title": "DO (S_O)",
"type": "stat" "type": "stat"
@@ -37,7 +37,7 @@
"id": 3, "id": 3,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" }, "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^S_NH/)\n |> last()", "refId": "A" } { "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^S_NH/)\n |> group(columns:[\"_field\"])\n |> last()", "refId": "A" }
], ],
"title": "NH\u2084 (S_NH)", "title": "NH\u2084 (S_NH)",
"type": "stat" "type": "stat"
@@ -49,7 +49,7 @@
"id": 4, "id": 4,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" }, "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^S_NO/)\n |> last()", "refId": "A" } { "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^S_NO/)\n |> group(columns:[\"_field\"])\n |> last()", "refId": "A" }
], ],
"title": "NO\u2083 (S_NO)", "title": "NO\u2083 (S_NO)",
"type": "stat" "type": "stat"
@@ -61,7 +61,7 @@
"id": 5, "id": 5,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" }, "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^X_TS/)\n |> last()", "refId": "A" } { "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^X_TS/)\n |> group(columns:[\"_field\"])\n |> last()", "refId": "A" }
], ],
"title": "TSS (X_TS)", "title": "TSS (X_TS)",
"type": "stat" "type": "stat"
@@ -74,7 +74,7 @@
"id": 7, "id": 7,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\")\n |> filter(fn:(r) => r._field =~ /^(F|S_O|S_NH|S_NO|S_S|X_TS)/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } { "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\")\n |> filter(fn:(r) => r._field =~ /^(F|S_O|S_NH|S_NO|S_S|X_TS)/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" }
], ],
"title": "Core Process Signals", "title": "Core Process Signals",
"type": "timeseries" "type": "timeseries"

View File

@@ -25,7 +25,7 @@
"options": { "legend": { "displayMode": "list", "placement": "bottom" } }, "options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\")\n |> filter(fn:(r) => r._field =~ /^(F_in|F_eff|F_so|F_sr|C_TS)/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\")\n |> filter(fn:(r) => r._field =~ /^(F_in|F_eff|F_so|F_sr|C_TS)/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A" "refId": "A"
} }
], ],

View File

@@ -3,7 +3,10 @@
"list": [ "list": [
{ {
"builtIn": 1, "builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" }, "datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true, "enable": true,
"hide": true, "hide": true,
"iconColor": "rgba(0, 211, 255, 1)", "iconColor": "rgba(0, 211, 255, 1)",
@@ -17,14 +20,42 @@
"id": null, "id": null,
"links": [], "links": [],
"panels": [ "panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Realtime Valve", "type": "row" },
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "gridPos": {
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 1 }, "h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"title": "Realtime Valve",
"type": "row"
},
{
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"gridPos": {
"h": 5,
"w": 8,
"x": 0,
"y": 1
},
"id": 2, "id": 2,
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "/.*/"
},
"colorMode": "value",
"graphMode": "none"
},
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -30d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"state\" or r._field==\"mode\" or r._field==\"percentageOpen\"))\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -30d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"state\" or r._field==\"mode\" or r._field==\"percentageOpen\"))\n |> group(columns:[\"_field\"])\n |> last()\n |> drop(columns:[\"_time\",\"_start\",\"_stop\"])",
"refId": "A" "refId": "A"
} }
], ],
@@ -32,23 +63,45 @@
"type": "stat" "type": "stat"
}, },
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "datasource": {
"fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] }, "type": "influxdb",
"gridPos": { "h": 9, "w": 16, "x": 8, "y": 1 }, "uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 16,
"x": 8,
"y": 1
},
"id": 3, "id": 3,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } }, "options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
}
},
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"downstream_predicted_flow\" or r._field==\"downstream_measured_flow\" or r._field==\"delta_predicted_pressure\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"downstream_predicted_flow\" or r._field==\"downstream_measured_flow\" or r._field==\"delta_predicted_pressure\"))\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A" "refId": "A"
} }
], ],
"title": "Flow + ΔP", "title": "Flow + \u0394P",
"type": "timeseries" "type": "timeseries"
} }
], ],
"schemaVersion": 39, "schemaVersion": 39,
"tags": ["EVOLV", "valve", "template"], "tags": [
"EVOLV",
"valve",
"template"
],
"templating": { "templating": {
"list": [ "list": [
{ {
@@ -56,30 +109,62 @@
"type": "custom", "type": "custom",
"label": "dbase", "label": "dbase",
"query": "cdzg44tv250jkd", "query": "cdzg44tv250jkd",
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false }, "current": {
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }], "text": "cdzg44tv250jkd",
"value": "cdzg44tv250jkd",
"selected": false
},
"options": [
{
"text": "cdzg44tv250jkd",
"value": "cdzg44tv250jkd",
"selected": true
}
],
"hide": 2 "hide": 2
}, },
{ {
"name": "measurement", "name": "measurement",
"type": "custom", "type": "custom",
"query": "template", "query": "template",
"current": { "text": "template", "value": "template", "selected": false }, "current": {
"options": [{ "text": "template", "value": "template", "selected": true }] "text": "template",
"value": "template",
"selected": false
},
"options": [
{
"text": "template",
"value": "template",
"selected": true
}
]
}, },
{ {
"name": "bucket", "name": "bucket",
"type": "custom", "type": "custom",
"query": "lvl2", "query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false }, "current": {
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }] "text": "lvl2",
"value": "lvl2",
"selected": false
},
"options": [
{
"text": "lvl2",
"value": "lvl2",
"selected": true
}
]
} }
] ]
}, },
"time": { "from": "now-6h", "to": "now" }, "time": {
"from": "now-6h",
"to": "now"
},
"timezone": "", "timezone": "",
"title": "template", "title": "template",
"uid": null, "uid": null,
"version": 1 "version": 1
} }

View File

@@ -3,7 +3,10 @@
"list": [ "list": [
{ {
"builtIn": 1, "builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" }, "datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true, "enable": true,
"hide": true, "hide": true,
"iconColor": "rgba(0, 211, 255, 1)", "iconColor": "rgba(0, 211, 255, 1)",
@@ -17,38 +20,88 @@
"id": null, "id": null,
"links": [], "links": [],
"panels": [ "panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Realtime Valve Group", "type": "row" },
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "gridPos": {
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 1 }, "h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"title": "Realtime Valve Group",
"type": "row"
},
{
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"gridPos": {
"h": 5,
"w": 8,
"x": 0,
"y": 1
},
"id": 2, "id": 2,
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "/.*/"
},
"colorMode": "value",
"graphMode": "none"
},
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -30d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"mode\" or r._field==\"maxDeltaP\"))\n |> last()", "query": "from(bucket: \"${bucket}\")\n |> range(start: -30d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"mode\" or r._field==\"maxDeltaP\"))\n |> group(columns:[\"_field\"])\n |> last()\n |> drop(columns:[\"_time\",\"_start\",\"_stop\"])",
"refId": "A" "refId": "A"
} }
], ],
"title": "Mode / maxΔP (last)", "title": "Mode / max\u0394P (last)",
"type": "stat" "type": "stat"
}, },
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "datasource": {
"fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] }, "type": "influxdb",
"gridPos": { "h": 9, "w": 16, "x": 8, "y": 1 }, "uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 16,
"x": 8,
"y": 1
},
"id": 3, "id": 3,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } }, "options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
}
},
"targets": [ "targets": [
{ {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field =~ /predicted_flow|measured_flow/ or r._field==\"maxDeltaP\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field =~ /predicted_flow|measured_flow/ or r._field==\"maxDeltaP\"))\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A" "refId": "A"
} }
], ],
"title": "Flow + maxΔP", "title": "Flow + max\u0394P",
"type": "timeseries" "type": "timeseries"
} }
], ],
"schemaVersion": 39, "schemaVersion": 39,
"tags": ["EVOLV", "valveGroupControl", "template"], "tags": [
"EVOLV",
"valveGroupControl",
"template"
],
"templating": { "templating": {
"list": [ "list": [
{ {
@@ -56,30 +109,62 @@
"type": "custom", "type": "custom",
"label": "dbase", "label": "dbase",
"query": "cdzg44tv250jkd", "query": "cdzg44tv250jkd",
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false }, "current": {
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }], "text": "cdzg44tv250jkd",
"value": "cdzg44tv250jkd",
"selected": false
},
"options": [
{
"text": "cdzg44tv250jkd",
"value": "cdzg44tv250jkd",
"selected": true
}
],
"hide": 2 "hide": 2
}, },
{ {
"name": "measurement", "name": "measurement",
"type": "custom", "type": "custom",
"query": "template", "query": "template",
"current": { "text": "template", "value": "template", "selected": false }, "current": {
"options": [{ "text": "template", "value": "template", "selected": true }] "text": "template",
"value": "template",
"selected": false
},
"options": [
{
"text": "template",
"value": "template",
"selected": true
}
]
}, },
{ {
"name": "bucket", "name": "bucket",
"type": "custom", "type": "custom",
"query": "lvl2", "query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false }, "current": {
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }] "text": "lvl2",
"value": "lvl2",
"selected": false
},
"options": [
{
"text": "lvl2",
"value": "lvl2",
"selected": true
}
]
} }
] ]
}, },
"time": { "from": "now-6h", "to": "now" }, "time": {
"from": "now-6h",
"to": "now"
},
"timezone": "", "timezone": "",
"title": "template", "title": "template",
"uid": null, "uid": null,
"version": 1 "version": 1
} }

View File

@@ -13,6 +13,7 @@
protocol: { value: 'http' }, protocol: { value: 'http' },
host: { value: 'localhost' }, host: { value: 'localhost' },
port: { value: 3000 }, port: { value: 3000 },
folderTitle: { value: '' },
folderUid: { value: '' }, folderUid: { value: '' },
defaultBucket: { value: '' }, defaultBucket: { value: '' },
}, },
@@ -47,7 +48,7 @@
window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node); window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node);
} }
['name', 'protocol', 'host', 'port', 'folderUid', 'defaultBucket'].forEach((field) => { ['name', 'protocol', 'host', 'port', 'folderTitle', 'folderUid', 'defaultBucket'].forEach((field) => {
const element = document.getElementById(`node-input-${field}`); const element = document.getElementById(`node-input-${field}`);
if (!element) return; if (!element) return;
node[field] = field === 'port' ? parseInt(element.value, 10) || 3000 : element.value || ''; node[field] = field === 'port' ? parseInt(element.value, 10) || 3000 : element.value || '';
@@ -87,9 +88,14 @@
<input type="password" id="node-input-bearerToken" placeholder="encrypted at rest" style="width:70%;" /> <input type="password" id="node-input-bearerToken" placeholder="encrypted at rest" style="width:70%;" />
</div> </div>
<div class="form-row">
<label for="node-input-folderTitle"><i class="fa fa-folder"></i> Grafana Folder</label>
<input type="text" id="node-input-folderTitle" placeholder="folder name e.g. EVOLV — resolved/created by name" style="width:70%;" />
</div>
<div class="form-row"> <div class="form-row">
<label for="node-input-folderUid"><i class="fa fa-folder-open"></i> Grafana Folder UID</label> <label for="node-input-folderUid"><i class="fa fa-folder-open"></i> Grafana Folder UID</label>
<input type="text" id="node-input-folderUid" placeholder="optional — empty = General folder" style="width:70%;" /> <input type="text" id="node-input-folderUid" placeholder="optional fallback — leave empty when Folder name is set" style="width:70%;" />
</div> </div>
<div class="form-row"> <div class="form-row">

View File

@@ -1,6 +1,70 @@
[ [
{"id":"dashboardAPI_basic_tab","type":"tab","label":"dashboardAPI basic","disabled":false,"info":"dashboardAPI basic example"}, {
{"id":"dashboardAPI_basic_node","type":"dashboardapi","z":"dashboardAPI_basic_tab","name":"dashboardAPI basic","x":420,"y":180,"wires":[["dashboardAPI_basic_dbg"]]}, "id": "dashboardAPI_basic_tab",
{"id":"dashboardAPI_basic_inj","type":"inject","z":"dashboardAPI_basic_tab","name":"basic trigger","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"ping","payload":"1","payloadType":"str","x":160,"y":180,"wires":[["dashboardAPI_basic_node"]]}, "type": "tab",
{"id":"dashboardAPI_basic_dbg","type":"debug","z":"dashboardAPI_basic_tab","name":"dashboardAPI basic debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]} "label": "dashboardAPI basic — measurement → Grafana",
"disabled": false,
"info": "Demonstrates the round-trip:\n- inject simulates a child.register message from a measurement node\n- dashboardapi composes a Grafana dashboard for that child\n- http request posts the dashboard to Grafana\n- debug shows the HTTP response\n\nConfigure the dashboardapi node with your Grafana host/port + bearer token\n(encrypted via Node-RED credentials). Default targets http://grafana:3000\nfrom inside the Docker compose stack."
},
{
"id": "dashboardAPI_basic_node",
"type": "dashboardapi",
"z": "dashboardAPI_basic_tab",
"name": "dashboardAPI",
"protocol": "http",
"host": "grafana",
"port": 3000,
"folderUid": "",
"defaultBucket": "telemetry",
"x": 460,
"y": 200,
"wires": [["dashboardAPI_basic_http"]]
},
{
"id": "dashboardAPI_basic_inj",
"type": "inject",
"z": "dashboardAPI_basic_tab",
"name": "simulate child.register (measurement)",
"props": [
{ "p": "topic", "vt": "str" },
{ "p": "payload", "v": "{\"config\":{\"general\":{\"id\":\"meas-demo-001\",\"name\":\"FT-001 demo\"},\"functionality\":{\"softwareType\":\"measurement\",\"positionVsParent\":\"downstream\"}}}", "vt": "json" }
],
"topic": "child.register",
"x": 180,
"y": 200,
"wires": [["dashboardAPI_basic_node"]]
},
{
"id": "dashboardAPI_basic_http",
"type": "http request",
"z": "dashboardAPI_basic_tab",
"name": "POST /api/dashboards/db",
"method": "use",
"ret": "obj",
"paytoqs": "ignore",
"url": "",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"x": 720,
"y": 200,
"wires": [["dashboardAPI_basic_dbg"]]
},
{
"id": "dashboardAPI_basic_dbg",
"type": "debug",
"z": "dashboardAPI_basic_tab",
"name": "Grafana response",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 960,
"y": 200,
"wires": []
}
] ]

View File

@@ -1,6 +1,6 @@
{ {
"name": "dashboardAPI", "name": "dashboardAPI",
"version": "1.0.0", "version": "1.1.0",
"description": "EVOLV Grafana dashboard generator (Node-RED node).", "description": "EVOLV Grafana dashboard generator (Node-RED node).",
"main": "dashboardAPI.js", "main": "dashboardAPI.js",
"scripts": { "scripts": {

View File

@@ -24,7 +24,7 @@ function resolveChildNode(childId, ctx) {
// Shared emit path used by both child.register (auto, deploy-driven) and // Shared emit path used by both child.register (auto, deploy-driven) and
// regenerate-dashboard (manual). `trigger` distinguishes the two for logs. // regenerate-dashboard (manual). `trigger` distinguishes the two for logs.
function emitDashboardsFor(source, childSource, ctx, msg, trigger) { async function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
const dashboards = source.generateDashboardsForGraph(childSource, { const dashboards = source.generateDashboardsForGraph(childSource, {
includeChildren: Boolean(msg.includeChildren ?? true), includeChildren: Boolean(msg.includeChildren ?? true),
}); });
@@ -34,6 +34,13 @@ function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
const token = source.config?.grafanaConnector?.bearerToken; const token = source.config?.grafanaConnector?.bearerToken;
if (token) headers.Authorization = `Bearer ${token}`; if (token) headers.Authorization = `Bearer ${token}`;
// Resolve the folder by name (creating it if missing) so a rebuilt Grafana's
// fresh folder uid never strands the upserts on a stale pinned uid. Falls
// back to the configured folderUid on any failure.
const folderUid = typeof source.resolveFolderUid === 'function'
? await source.resolveFolderUid()
: (source.config?.grafanaConnector?.folderUid || undefined);
for (const dash of dashboards) { for (const dash of dashboards) {
ctx.send({ ctx.send({
...msg, ...msg,
@@ -43,7 +50,7 @@ function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
headers, headers,
payload: source.buildUpsertRequest({ payload: source.buildUpsertRequest({
dashboard: dash.dashboard, dashboard: dash.dashboard,
folderUid: source.config?.grafanaConnector?.folderUid || undefined, folderUid: folderUid || undefined,
overwrite: true, overwrite: true,
}), }),
meta: { meta: {
@@ -74,7 +81,7 @@ function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
// payload's `diff` indicates that NEITHER the dashboardAPI itself NOR this // payload's `diff` indicates that NEITHER the dashboardAPI itself NOR this
// child NOR its grandchildren changed, skip composition and log no-diff. The // child NOR its grandchildren changed, skip composition and log no-diff. The
// first call after startup (no cached diff yet) regenerates unconditionally. // first call after startup (no cached diff yet) regenerates unconditionally.
function registerChild(source, msg, ctx) { async function registerChild(source, msg, ctx) {
const childSource = resolveChildSource(msg.payload, ctx); const childSource = resolveChildSource(msg.payload, ctx);
if (!childSource?.config) { if (!childSource?.config) {
throw new Error('Missing or invalid child node'); throw new Error('Missing or invalid child node');
@@ -99,13 +106,13 @@ function registerChild(source, msg, ctx) {
return; return;
} }
emitDashboardsFor(source, childSource, ctx, msg, 'child.register'); await emitDashboardsFor(source, childSource, ctx, msg, 'child.register');
} }
// On regenerate-dashboard: re-emit dashboards for every cached child source, // On regenerate-dashboard: re-emit dashboards for every cached child source,
// bypassing the diff predicate. Useful as an operator escape hatch when // bypassing the diff predicate. Useful as an operator escape hatch when
// auto-regen missed an edge case or when the operator just wants to refresh. // auto-regen missed an edge case or when the operator just wants to refresh.
function regenerateDashboard(source, msg, ctx) { async function regenerateDashboard(source, msg, ctx) {
const cached = source.cachedChildSources?.() || []; const cached = source.cachedChildSources?.() || [];
if (source.logger?.info) { if (source.logger?.info) {
source.logger.info({ source.logger.info({
@@ -116,7 +123,7 @@ function regenerateDashboard(source, msg, ctx) {
}); });
} }
for (const childSource of cached) { for (const childSource of cached) {
emitDashboardsFor(source, childSource, ctx, msg, 'manual'); await emitDashboardsFor(source, childSource, ctx, msg, 'manual');
} }
} }

View File

@@ -75,6 +75,7 @@ class nodeClass {
host: uiConfig.host || 'localhost', host: uiConfig.host || 'localhost',
port: Number(uiConfig.port || 3000), port: Number(uiConfig.port || 3000),
bearerToken, bearerToken,
folderTitle: uiConfig.folderTitle || '',
folderUid: uiConfig.folderUid || '', folderUid: uiConfig.folderUid || '',
}, },
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '', defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',

View File

@@ -18,6 +18,28 @@ function slugify(input) {
.slice(0, 60); .slice(0, 60);
} }
// Map a node's lowercased softwareType to its Grafana template file in config/.
// Nodes report softwareType as the lowercased node name (e.g. 'rotatingmachine',
// 'machinegroupcontrol'), but several template files are camelCase and some node
// types share a template (rotatingMachine → machine, diffuser → aeration). The
// keys here are always lowercase; lookup lowercases the input first.
const TEMPLATE_FILE_BY_SOFTWARE_TYPE = {
rotatingmachine: 'machine.json',
machine: 'machine.json',
machinegroupcontrol: 'machineGroup.json',
machinegroup: 'machineGroup.json',
pumpingstation: 'pumpingStation.json',
valvegroupcontrol: 'valveGroupControl.json',
diffuser: 'aeration.json',
aeration: 'aeration.json',
measurement: 'measurement.json',
monster: 'monster.json',
reactor: 'reactor.json',
settler: 'settler.json',
valve: 'valve.json',
dashboardapi: 'dashboardapi.json',
};
function defaultBucketForPosition(positionVsParent) { function defaultBucketForPosition(positionVsParent) {
const pos = String(positionVsParent || '').toLowerCase(); const pos = String(positionVsParent || '').toLowerCase();
if (pos === 'upstream') return 'lvl1'; if (pos === 'upstream') return 'lvl1';
@@ -25,6 +47,20 @@ function defaultBucketForPosition(positionVsParent) {
return 'lvl2'; return 'lvl2';
} }
// Replace `{{name}}` placeholders in a raw JSON template string with values
// from `vars`. Unknown placeholders are left intact. Used to inject node-config
// derived constants (basin geometry, threshold y-positions) into a template
// before JSON.parse — so the resulting dashboard has concrete numbers in
// fieldConfig.thresholds and canvas element placements. Mustache-style braces
// keep these placeholders distinct from Grafana's own `${var}` dashboard
// variables (which are interpreted by Grafana at render time).
function substituteTemplateVars(rawJson, vars) {
if (!vars || !Object.keys(vars).length) return rawJson;
return rawJson.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (m, key) => (
Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : m
));
}
function updateTemplatingVar(dashboard, varName, value) { function updateTemplatingVar(dashboard, varName, value) {
const list = dashboard?.templating?.list; const list = dashboard?.templating?.list;
if (!Array.isArray(list)) return; if (!Array.isArray(list)) return;
@@ -64,6 +100,12 @@ class DashboardApi {
host: config?.grafanaConnector?.host || 'localhost', host: config?.grafanaConnector?.host || 'localhost',
port: Number(config?.grafanaConnector?.port || 3000), port: Number(config?.grafanaConnector?.port || 3000),
bearerToken: config?.grafanaConnector?.bearerToken || '', bearerToken: config?.grafanaConnector?.bearerToken || '',
// folderTitle is the durable way to target a folder: Grafana folder
// uids change whenever the instance is rebuilt, so a pinned folderUid
// goes stale (every upsert then 400s "folder not found"). When set, the
// uid is resolved (and the folder created if absent) by name at emit
// time. folderUid stays supported as an explicit override / fallback.
folderTitle: config?.grafanaConnector?.folderTitle || '',
folderUid: config?.grafanaConnector?.folderUid || '', folderUid: config?.grafanaConnector?.folderUid || '',
}, },
defaultBucket: config?.defaultBucket || '', defaultBucket: config?.defaultBucket || '',
@@ -98,9 +140,9 @@ class DashboardApi {
_templateFileForSoftwareType(softwareType) { _templateFileForSoftwareType(softwareType) {
const st = String(softwareType || '').trim(); const st = String(softwareType || '').trim();
const candidates = [ const candidates = [
TEMPLATE_FILE_BY_SOFTWARE_TYPE[st.toLowerCase()],
`${st}.json`, `${st}.json`,
`${st.toLowerCase()}.json`, `${st.toLowerCase()}.json`,
st === 'machineGroupControl' ? 'machineGroup.json' : null,
].filter(Boolean); ].filter(Boolean);
for (const filename of candidates) { for (const filename of candidates) {
@@ -112,13 +154,97 @@ class DashboardApi {
return null; return null;
} }
loadTemplate(softwareType) { loadTemplate(softwareType, templateVars = null) {
const templatePath = this._templateFileForSoftwareType(softwareType); const templatePath = this._templateFileForSoftwareType(softwareType);
if (!templatePath) return null; if (!templatePath) return null;
const raw = fs.readFileSync(templatePath, 'utf8'); let raw = fs.readFileSync(templatePath, 'utf8');
// Always substitute — falls back to per-softwareType defaults so the
// template is JSON-parseable even when no nodeConfig is provided (tests,
// smoke-loading, etc.). _templateVarsForNode returns {} for types that
// don't use placeholders, which is a no-op pass.
const vars = templateVars || this._templateVarsForNode(softwareType, null);
raw = substituteTemplateVars(raw, vars);
return JSON.parse(raw); return JSON.parse(raw);
} }
// Per-softwareType numeric vars baked into the template before JSON.parse.
// Today only pumpingStation needs this (basin geometry → bar-gauge thresholds
// and canvas y-positions). Other types return {} and skip substitution.
_templateVarsForNode(softwareType, nodeConfig) {
const st = String(softwareType || '').toLowerCase();
if (st !== 'pumpingstation') return {};
// configManager.buildConfig nests basin geometry under `basin.*` and
// safety percentages under `safety.*` (see generalFunctions/configManager).
const basin = nodeConfig?.basin || {};
const safety = nodeConfig?.safety || {};
const heightBasin = Number(basin.height) || 4;
const inflowLevel = Number(basin.inflowLevel) || 0;
const outflowLevel = Number(basin.outflowLevel) || 0;
const overflowLevel = Number(basin.overflowLevel) || heightBasin;
const dryRunPct = Number(safety.dryRunThresholdPercent) || 30;
const highPct = Number(safety.highVolumeSafetyThresholdPercent) || 90;
// Mirror specificClass._computeSafetyPoints derivation (pumpingStation).
const dryRunLevel = outflowLevel * (1 + dryRunPct / 100);
const highSafetyLevel = overflowLevel * (highPct / 100);
// Canvas tank: rim at y=20px, floor at y=700px (680px tall). Must match
// hard-coded tank rectangle placement in config/pumpingStation.json
// (basin row is h:20 grid rows; canvas root frame is 400x760 px — taller
// than wide to match the card's aspect ratio so the tank fills the card
// vertically with no letterboxing).
const TANK_TOP = 20, TANK_BOT = 700, TANK_H = TANK_BOT - TANK_TOP;
const yFor = (v) => +(TANK_BOT - (v / heightBasin) * TANK_H).toFixed(2);
const tyFor = (yLine) => +(yLine - 8).toFixed(2); // centre 16px text on the line
const y_overflow = yFor(overflowLevel);
const y_highSafety = yFor(highSafetyLevel);
const y_inflow = yFor(inflowLevel);
const y_dryRun = yFor(dryRunLevel);
const y_outflow = yFor(outflowLevel);
// Label y-positions get min-gap enforcement so labels never overlap even
// when thresholds sit nearly on top of each other (e.g. dryRun=2 % means
// dryRunLevel sits right on outflowLevel; highSafety=98 % puts it under
// overflow). Lines stay at proportional y; only the label text moves.
// Two-pass (down + up) mirrors editor's basin-diagram.js placement logic.
const GAP = 20;
const labels = [
{ id: 'overflow', y: tyFor(y_overflow) },
{ id: 'highSafety', y: tyFor(y_highSafety) },
{ id: 'inflow', y: tyFor(y_inflow) },
{ id: 'dryRun', y: tyFor(y_dryRun) },
{ id: 'outflow', y: tyFor(y_outflow) },
].sort((a, b) => a.y - b.y);
for (let i = 1; i < labels.length; i++) {
if (labels[i].y < labels[i - 1].y + GAP) labels[i].y = labels[i - 1].y + GAP;
}
for (let i = labels.length - 2; i >= 0; i--) {
if (labels[i].y > labels[i + 1].y - GAP) labels[i].y = labels[i + 1].y - GAP;
}
const ty = Object.fromEntries(labels.map((l) => [l.id, +l.y.toFixed(2)]));
return {
heightBasin: +heightBasin.toFixed(2),
outflowLevel: +outflowLevel.toFixed(3),
inflowLevel: +inflowLevel.toFixed(3),
overflowLevel: +overflowLevel.toFixed(3),
dryRunLevel: +dryRunLevel.toFixed(3),
highSafetyLevel: +highSafetyLevel.toFixed(3),
y_overflow, y_highSafety, y_inflow, y_dryRun, y_outflow,
h_spill: +(y_overflow - TANK_TOP).toFixed(2),
h_highSafety: +(y_highSafety - y_overflow).toFixed(2),
h_operating: +(y_outflow - y_highSafety).toFixed(2),
h_dead: +(TANK_BOT - y_outflow).toFixed(2),
ty_overflow: ty.overflow,
ty_highSafety: ty.highSafety,
ty_inflow: ty.inflow,
ty_dryRun: ty.dryRun,
ty_outflow: ty.outflow,
};
}
// Collect every `meta.emittedFields` declared by panels in a template. // Collect every `meta.emittedFields` declared by panels in a template.
// Used by #39's parent panel filter — a parent panel whose emittedFields // Used by #39's parent panel filter — a parent panel whose emittedFields
// are fully covered by its children's panels is removed. // are fully covered by its children's panels is removed.
@@ -136,17 +262,92 @@ class DashboardApi {
return `${protocol}://${host}:${port}/api/dashboards/db`; return `${protocol}://${host}:${port}/api/dashboards/db`;
} }
grafanaFoldersUrl() {
const { protocol, host, port } = this.config.grafanaConnector;
return `${protocol}://${host}:${port}/api/folders`;
}
_grafanaJsonHeaders() {
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
const token = this.config.grafanaConnector.bearerToken;
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
}
// Resolve the target Grafana folder uid by NAME, creating the folder if it
// doesn't exist. This is the durable alternative to a pinned folderUid, which
// goes stale on every Grafana rebuild (the new instance hands the same-named
// folder a fresh uid, and every dashboard upsert then 400s "folder not
// found"). Resolution is done once per process and cached.
//
// Degradation contract: any failure (no fetch, network error, non-OK
// response) logs a warning and falls back to the configured folderUid, so the
// node is never worse off than the pinned-uid behavior it replaces.
async resolveFolderUid({ fetchImpl = globalThis.fetch } = {}) {
const gc = this.config.grafanaConnector;
const title = String(gc.folderTitle || '').trim();
// No title configured → legacy behavior: use the explicit uid (may be '').
if (!title) return gc.folderUid || '';
if (this._resolvedFolderUid) return this._resolvedFolderUid;
if (typeof fetchImpl !== 'function') {
this.logger.warn('resolveFolderUid: no fetch implementation available; using configured folderUid');
return gc.folderUid || '';
}
try {
const uid = await this._lookupOrCreateFolder(title, fetchImpl);
if (uid) {
this._resolvedFolderUid = uid;
return uid;
}
} catch (err) {
this.logger.warn(`resolveFolderUid failed (${err?.message || err}); using configured folderUid`);
}
return gc.folderUid || '';
}
async _lookupOrCreateFolder(title, fetchImpl) {
const url = this.grafanaFoldersUrl();
const headers = this._grafanaJsonHeaders();
const listRes = await fetchImpl(url, { method: 'GET', headers });
if (listRes?.ok) {
const folders = await listRes.json();
const match = Array.isArray(folders)
&& folders.find((f) => String(f?.title || '').trim().toLowerCase() === title.toLowerCase());
if (match?.uid) {
this.logger.info({ event: 'folder-resolved', outcome: 'found', title, uid: match.uid });
return match.uid;
}
} else {
this.logger.warn(`resolveFolderUid: GET /api/folders -> ${listRes?.status}`);
}
const createRes = await fetchImpl(url, { method: 'POST', headers, body: JSON.stringify({ title }) });
if (createRes?.ok) {
const created = await createRes.json();
this.logger.info({ event: 'folder-resolved', outcome: 'created', title, uid: created?.uid });
return created?.uid || '';
}
this.logger.warn(`resolveFolderUid: POST /api/folders -> ${createRes?.status}`);
return '';
}
buildDashboard({ nodeConfig, positionVsParent }) { buildDashboard({ nodeConfig, positionVsParent }) {
const softwareType = const softwareType =
nodeConfig?.functionality?.softwareType || nodeConfig?.functionality?.softwareType ||
nodeConfig?.functionality?.software_type || nodeConfig?.functionality?.software_type ||
'measurement'; 'measurement';
const nodeId = nodeConfig?.general?.id || nodeConfig?.general?.name || softwareType; const nodeId = nodeConfig?.general?.id || nodeConfig?.general?.name || softwareType;
const measurementName = `${softwareType}_${nodeId}`; // Mirror outputUtils.formatMsg: telemetry is written under general.name when
// set, falling back to `<softwareType>_<id>`. The dashboard's _measurement var
// must match that exactly or every panel queries a non-existent series.
const measurementName =
nodeConfig?.general?.name || `${softwareType}_${nodeConfig?.general?.id || softwareType}`;
const title = nodeConfig?.general?.name || String(nodeId); const title = nodeConfig?.general?.name || String(nodeId);
// Missing templates are treated as non-fatal: we skip only that dashboard. // Missing templates are treated as non-fatal: we skip only that dashboard.
const dashboard = this.loadTemplate(softwareType); const templateVars = this._templateVarsForNode(softwareType, nodeConfig);
const dashboard = this.loadTemplate(softwareType, templateVars);
if (!dashboard) { if (!dashboard) {
this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`); this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`);
return null; return null;
@@ -168,7 +369,7 @@ class DashboardApi {
updateTemplatingVar(dashboard, 'measurement', measurementName); updateTemplatingVar(dashboard, 'measurement', measurementName);
updateTemplatingVar(dashboard, 'bucket', bucket); updateTemplatingVar(dashboard, 'bucket', bucket);
return { dashboard, uid, title, softwareType, nodeId, measurementName }; return { dashboard, uid, title, softwareType, nodeId, measurementName, bucket };
} }
buildUpsertRequest({ dashboard, folderId, folderUid, overwrite = true }) { buildUpsertRequest({ dashboard, folderId, folderUid, overwrite = true }) {
@@ -208,90 +409,346 @@ class DashboardApi {
return false; return false;
} }
// Collect ids that constitute "this dashboardAPI + this child + its grandchildren" // Collect every node id in "this dashboardAPI + this child's full subtree" for
// for the diff predicate. Pulls grandchildren via the existing extractChildren walk. // the diff predicate. Recurses the whole registered-child tree (not just
// grandchildren) so a change anywhere below a wired root triggers a regen.
// `visited` guards cycles / diamond topologies.
subtreeIdsFor(dashboardApiNodeId, childSource) { subtreeIdsFor(dashboardApiNodeId, childSource) {
const ids = new Set(); const ids = new Set();
if (dashboardApiNodeId) ids.add(dashboardApiNodeId); if (dashboardApiNodeId) ids.add(dashboardApiNodeId);
const childId = childSource?.config?.general?.id; this._collectSubtreeIds(childSource, ids, new Set());
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; return ids;
} }
_collectSubtreeIds(nodeSource, ids, visited) {
const id = nodeSource?.config?.general?.id;
if (id) {
if (visited.has(id)) return;
visited.add(id);
ids.add(id);
}
for (const { childSource } of this.extractChildren(nodeSource)) {
this._collectSubtreeIds(childSource, ids, visited);
}
}
// Compose a dashboard for a wired root and EVERY descendant in its registered-
// child tree. Operators wire only subtree roots; dashboardAPI recurses the
// parent-child relationships to discover the rest. Returns a flat, pre-order
// array (root first) of buildDashboard results.
generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) { generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) {
if (!rootSource?.config) { if (!rootSource?.config) {
this.logger.warn('generateDashboardsForGraph skipped: root source missing config'); this.logger.warn('generateDashboardsForGraph skipped: root source missing config');
return []; return [];
} }
const rootPosition = rootSource?.positionVsParent || rootSource?.config?.functionality?.positionVsParent; const results = [];
const rootDash = this.buildDashboard({ nodeConfig: rootSource.config, positionVsParent: rootPosition }); this._composeNode(rootSource, includeChildren, results, new Set());
if (!rootDash) return [];
const results = [rootDash];
if (!includeChildren) return results;
const children = this.extractChildren(rootSource);
for (const { childSource, positionVsParent } of children) {
const childDash = this.buildDashboard({ nodeConfig: childSource.config, positionVsParent });
if (childDash) results.push(childDash);
}
// 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 : [];
for (const { childSource } of children) {
const childConfig = childSource.config;
const childSoftwareType = childConfig?.functionality?.softwareType || 'measurement';
const childNodeId = childConfig?.general?.id || childConfig?.general?.name || childSoftwareType;
const childUid = stableUid(`${childSoftwareType}:${childNodeId}`);
const childTitle = childConfig?.general?.name || String(childNodeId);
rootDash.dashboard.links.push({
type: 'link',
title: childTitle,
url: `/d/${childUid}/${slugify(childTitle)}`,
tags: [],
targetBlank: false,
keepTime: true,
keepVariables: true,
});
}
}
return results; return results;
} }
// Recursively compose `nodeSource` then its descendants. Per-parent dedup and
// links are applied at every level (each parent is deduped against / links to
// its own direct children). `visited` ensures one dashboard per node id even
// when the topology has cycles or diamonds.
_composeNode(nodeSource, includeChildren, results, visited) {
const nodeId = nodeSource?.config?.general?.id;
if (nodeId) {
if (visited.has(nodeId)) return null;
visited.add(nodeId);
}
const position = nodeSource?.positionVsParent || nodeSource?.config?.functionality?.positionVsParent;
const nodeDash = this.buildDashboard({ nodeConfig: nodeSource.config, positionVsParent: position });
if (!nodeDash) return null;
results.push(nodeDash);
if (!includeChildren) return nodeDash;
const children = this.extractChildren(nodeSource);
const childDashes = [];
for (const { childSource } of children) {
const childDash = this._composeNode(childSource, includeChildren, results, visited);
if (childDash) childDashes.push(childDash);
}
this._dedupParentPanels(nodeDash, childDashes);
this._linkToChildren(nodeDash, children);
// Inject the per-pump fan-out panels AFTER dedup so they survive: these
// panels intentionally aggregate child data onto the parent dashboard
// (the operator wants every pump on one MGC graph), which is exactly what
// the no-duplication rule strips elsewhere. Run last so nothing removes them.
this._injectMachineGroupPumpPanels(nodeDash, children);
return nodeDash;
}
// No-data-duplication rule (PRD F-5, #39): remove a parent's panels whose
// emittedFields are fully covered by its direct children's panels, so the
// same series isn't rendered twice across the parent/child dashboards.
_dedupParentPanels(parentDash, childDashes) {
if (childDashes.length === 0 || !parentDash.dashboard) return;
const childCoveredFields = new Set();
for (const dash of childDashes) {
for (const f of this.collectEmittedFields(dash.dashboard)) childCoveredFields.add(f);
}
const before = parentDash.dashboard.panels.length;
parentDash.dashboard.panels = parentDash.dashboard.panels.filter((p) => {
if (p.type === 'row') return true; // never drop rows
const fields = p?.meta?.emittedFields;
if (!Array.isArray(fields) || fields.length === 0) return true; // no declaration, keep
return !fields.every((f) => childCoveredFields.has(f));
});
if (this.logger?.debug && before !== parentDash.dashboard.panels.length) {
this.logger.debug({
event: 'parent-panels-deduped',
before,
after: parentDash.dashboard.panels.length,
rootTitle: parentDash.title,
});
}
}
_linkToChildren(parentDash, children) {
if (children.length === 0 || !parentDash.dashboard) return;
parentDash.dashboard.links = Array.isArray(parentDash.dashboard.links) ? parentDash.dashboard.links : [];
for (const { childSource } of children) {
const childConfig = childSource.config;
const childSoftwareType = childConfig?.functionality?.softwareType || 'measurement';
const childNodeId = childConfig?.general?.id || childConfig?.general?.name || childSoftwareType;
const childUid = stableUid(`${childSoftwareType}:${childNodeId}`);
const childTitle = childConfig?.general?.name || String(childNodeId);
parentDash.dashboard.links.push({
type: 'link',
title: childTitle,
url: `/d/${childUid}/${slugify(childTitle)}`,
tags: [],
targetBlank: false,
keepTime: true,
keepVariables: true,
});
}
}
// Software types that count as a "pump" child of a machine group. Mirrors the
// template-alias map: a rotatingMachine reports softwareType 'rotatingmachine'
// in production, 'machine' in tests / shared template.
static _PUMP_SOFTWARE_TYPES = new Set(['rotatingmachine', 'machine']);
// Replicate the measurement-name convention from outputUtils.formatMsg /
// buildDashboard so the dashboard queries the exact series each pump writes:
// `general.name` when set, else `<softwareType>_<id>`.
_measurementNameForConfig(config) {
const softwareType = config?.functionality?.softwareType || 'measurement';
return config?.general?.name || `${softwareType}_${config?.general?.id || softwareType}`;
}
// Datasource block reused for injected panels. Pull it off an existing panel
// so the dashboard keeps a single influxdb datasource uid; fall back to the
// template's known uid if every panel was deduped away.
_datasourceFor(dashboard) {
const withDs = (dashboard.panels || []).find((p) => p?.datasource?.type === 'influxdb');
return withDs?.datasource || { type: 'influxdb', uid: 'cdzg44tv250jkd' };
}
// Build the per-pump + group-aggregate timeseries panels for a machineGroup
// dashboard. The operator asked for one graph each of pump % control, pump
// predicted flow, and pump predicted power, with the group total folded in,
// the resolved demand overlaid on the flow graph, and the flow-capacity
// envelope drawn as dashed min/max lines.
//
// Per-pump series live in each pump's OWN InfluxDB measurement (not the
// MGC's), so the queries are generated at compose time from the known child
// topology. Pump series are kept by `_measurement` (legend = pump name);
// group series are kept by `_field` and renamed via byName overrides.
_injectMachineGroupPumpPanels(parentDash, children) {
if (!parentDash?.dashboard) return;
const st = String(parentDash.softwareType || '').toLowerCase();
if (st !== 'machinegroupcontrol' && st !== 'machinegroup') return;
const pumps = (children || [])
.map(({ childSource }) => childSource?.config)
.filter((c) => c && DashboardApi._PUMP_SOFTWARE_TYPES.has(
String(c?.functionality?.softwareType || '').toLowerCase()))
.map((c) => ({ measurement: this._measurementNameForConfig(c), title: c?.general?.name || c?.general?.id }));
if (pumps.length === 0) return; // No pumps wired → leave the static totals.
const dashboard = parentDash.dashboard;
const datasource = this._datasourceFor(dashboard);
// The richer flow/power panels below supersede the static group-total
// panels — drop them so the same series isn't drawn twice.
dashboard.panels = (dashboard.panels || []).filter(
(p) => p.title !== 'Total Flow' && p.title !== 'Total Power');
const measFilter = pumps.map((p) => `r._measurement == "${p.measurement}"`).join(' or ');
const nextId = Math.max(0, ...dashboard.panels.map((p) => Number(p.id) || 0)) + 1;
dashboard.panels.push(
this._pumpControlPanel({ datasource, measFilter, id: nextId, y: 6 }),
this._pumpFlowPanel({ datasource, measFilter, id: nextId + 1, y: 14 }),
this._pumpPowerPanel({ datasource, measFilter, id: nextId + 2, y: 22 }),
);
}
// ── Injected-panel builders ──────────────────────────────────────────────
// All three use `${bucket}` / `${measurement}` template vars (resolved by
// Grafana from the dashboard's templating list) plus literal pump measurement
// names. v.timeRangeStart/Stop/windowPeriod are Grafana-supplied.
_baseTsPanel({ datasource, id, y, title, targets, overrides = [], defaults = {} }) {
return {
datasource,
fieldConfig: {
defaults: { custom: { drawStyle: 'line', lineWidth: 2, fillOpacity: 5, showPoints: 'never' }, ...defaults },
overrides,
},
gridPos: { h: 8, w: 24, x: 0, y },
id,
options: { legend: { displayMode: 'list', placement: 'bottom' }, tooltip: { mode: 'multi' } },
targets,
title,
type: 'timeseries',
// Empty emittedFields: these panels intentionally duplicate child series
// and must never be removed by the no-duplication dedup pass.
meta: { emittedFields: [], dynamic: 'mgc-pump-fanout' },
};
}
// Pump series kept by `_measurement` → one line per pump, legend = pump name.
// `field` is exact-matched by default; pass `regex:true` to match a 4-segment
// MeasurementContainer key whose childId varies per pump. rotatingMachine
// writes its own predictions under childId = node id (e.g.
// `flow.predicted.atequipment.<pumpId>`), NOT a fixed `default`, so the
// flow/power series must match the position prefix, not an exact key.
_perPumpTarget({ measFilter, field, refId, transform = '', regex = false }) {
const fieldFilter = regex ? `r._field =~ /${field}/` : `r._field == "${field}"`;
return {
refId,
query:
`from(bucket: "\${bucket}")\n` +
` |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n` +
` |> filter(fn:(r) => (${measFilter}) and ${fieldFilter})\n` +
` |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n` +
transform +
` |> keep(columns: ["_time", "_value", "_measurement"])`,
};
}
// Group series kept by `_field` → legend = field name, renamed via byName
// overrides. `fields` is OR-joined into one query.
_groupFieldsTarget({ fields, refId }) {
const filter = fields.map((f) => `r._field == "${f}"`).join(' or ');
return {
refId,
query:
`from(bucket: "\${bucket}")\n` +
` |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n` +
` |> filter(fn:(r) => r._measurement == "\${measurement}" and (${filter}))\n` +
` |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n` +
` |> keep(columns: ["_time", "_value", "_field"])`,
};
}
_byName(name, properties) {
return { matcher: { id: 'byName', options: name }, properties };
}
_pumpControlPanel({ datasource, measFilter, id, y }) {
// Two series per pump so an operator can see at a glance whether each pump
// actually moved to where the MGC told it:
// • realized position — the bare `ctrl` field (getCurrentPosition), solid.
// • commanded setpoint — `ctrl.predicted.atequipment.<pumpId>`, the % the
// pump computed from the MGC flow command (calcCtrl reverse curve),
// drawn dashed. childId varies per pump, so match the position prefix.
// Both are already 0..100 %, so they map straight onto a % axis — no scaling.
// Each series' `_measurement` is suffixed so the legend distinguishes the
// two lines per pump ("Pump A (realized)" vs "Pump A (setpoint)").
const label = (name) =>
` |> map(fn: (r) => ({ r with _measurement: r._measurement + " (${name})" }))\n`;
return this._baseTsPanel({
datasource, id, y,
title: 'Pump % Control',
defaults: { unit: 'percent', min: 0, max: 100 },
targets: [
this._perPumpTarget({ measFilter, field: 'ctrl', refId: 'A', transform: label('realized') }),
this._perPumpTarget({
measFilter, field: '^ctrl\\.predicted\\.atequipment\\.', refId: 'B',
regex: true, transform: label('setpoint'),
}),
],
overrides: [{
matcher: { id: 'byRegexp', options: '.*\\(setpoint\\)' },
properties: [{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [6, 6] } }],
}],
});
}
_pumpFlowPanel({ datasource, measFilter, id, y }) {
return this._baseTsPanel({
datasource, id, y,
title: 'Pump Predicted Flow vs Demand',
defaults: { unit: 'm3/h' },
targets: [
this._perPumpTarget({ measFilter, field: '^flow\\.predicted\\.atequipment\\.', refId: 'A', regex: true }),
this._groupFieldsTarget({
refId: 'B',
fields: ['atEquipment_predicted_flow', 'demandFlow', 'demandPct', 'flowCapacityMin', 'flowCapacityMax'],
}),
],
overrides: [
this._byName('atEquipment_predicted_flow', [
{ id: 'displayName', value: 'Total flow' },
{ id: 'custom.lineWidth', value: 3 },
]),
this._byName('demandFlow', [
{ id: 'displayName', value: 'Flow demand (setpoint)' },
{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [6, 6] } },
{ id: 'color', value: { mode: 'fixed', fixedColor: 'blue' } },
]),
this._byName('demandPct', [
{ id: 'displayName', value: 'Demand %' },
{ id: 'unit', value: 'percent' },
{ id: 'custom.axisPlacement', value: 'right' },
{ id: 'custom.axisLabel', value: '% control' },
{ id: 'color', value: { mode: 'fixed', fixedColor: 'purple' } },
]),
this._byName('flowCapacityMin', [
{ id: 'displayName', value: 'Capacity min' },
{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [10, 10] } },
{ id: 'custom.fillOpacity', value: 0 },
{ id: 'color', value: { mode: 'fixed', fixedColor: 'orange' } },
]),
this._byName('flowCapacityMax', [
{ id: 'displayName', value: 'Capacity max' },
{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [10, 10] } },
{ id: 'custom.fillOpacity', value: 0 },
{ id: 'color', value: { mode: 'fixed', fixedColor: 'red' } },
]),
],
});
}
_pumpPowerPanel({ datasource, measFilter, id, y }) {
return this._baseTsPanel({
datasource, id, y,
title: 'Pump Predicted Power',
defaults: { unit: 'kwatt' },
targets: [
this._perPumpTarget({ measFilter, field: '^power\\.predicted\\.atequipment\\.', refId: 'A', regex: true }),
this._groupFieldsTarget({ refId: 'B', fields: ['atEquipment_predicted_power'] }),
],
overrides: [
this._byName('atEquipment_predicted_power', [
{ id: 'displayName', value: 'Total power' },
{ id: 'custom.lineWidth', value: 3 },
]),
],
});
}
} }
module.exports = DashboardApi; module.exports = DashboardApi;

68
test/_output-manifest.md Normal file
View File

@@ -0,0 +1,68 @@
# dashboardAPI output manifest
Per `.claude/rules/output-coverage.md`: every output on every layer, in every state.
## Port 0 (process — Grafana upsert messages)
Emitted by the command handler(s) after a `child.register` or `regenerate-dashboard` message. Shape is the same for both; `meta.trigger` distinguishes them.
| Key | Source method | Type | States tested | Test file |
|---|---|---|---|---|
| `topic` | `handlers.emitDashboardsFor` | `'create'` (literal) | populated | `test/basic/slice41-manual-regen.basic.test.js` |
| `url` | `source.grafanaUpsertUrl()` | string (configured Grafana endpoint) | populated, default-config | `test/basic/slice34-credentials-and-folder.basic.test.js` |
| `method` | `handlers.emitDashboardsFor` | `'POST'` (literal) | populated | `test/basic/slice41-manual-regen.basic.test.js` |
| `headers.Accept` | `handlers.emitDashboardsFor` | `'application/json'` (literal) | populated | _via output manifest test below_ |
| `headers['Content-Type']` | `handlers.emitDashboardsFor` | `'application/json'` (literal) | populated | _via output manifest test below_ |
| `headers.Authorization` | `handlers.emitDashboardsFor` | `'Bearer <token>'` when configured; absent when not | populated, absent (degraded — no token) | `test/basic/slice43-output-manifest.basic.test.js` |
| `payload.dashboard` | `source.buildDashboard()` | object (Grafana dashboard JSON) | populated, byte-identical-on-repeat | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js` |
| `payload.overwrite` | `source.buildUpsertRequest()` | `true` (literal) | populated | `test/basic/slice34-credentials-and-folder.basic.test.js` |
| `payload.folderUid` | `handlers.emitDashboardsFor``source.resolveFolderUid()` (by-name lookup/create, cached; falls back to configured `folderUid`) → `source.buildUpsertRequest()` | resolved uid string when `folderTitle` set or `folderUid` configured; absent when both empty | populated (resolved/found, created, fallback), absent (degraded — empty config) | `test/basic/slice48-folder-resolve-by-name.basic.test.js`, `test/basic/slice34-credentials-and-folder.basic.test.js` |
| `payload.folderId` | `source.buildUpsertRequest()` | number when explicitly passed; absent otherwise | absent (default), populated (explicit) | `test/basic/slice34-credentials-and-folder.basic.test.js` |
| `meta.nodeId` | `handlers.emitDashboardsFor` | string (child node id) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
| `meta.softwareType` | `handlers.emitDashboardsFor` | string (child softwareType) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
| `meta.uid` | `handlers.emitDashboardsFor` | string (stableUid hash, deterministic) | populated, byte-identical | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js` |
| `meta.title` | `handlers.emitDashboardsFor` | string (child name or id) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
| `meta.trigger` | `handlers.emitDashboardsFor` | `'child.register'` or `'manual'` | both states | `test/basic/slice41-manual-regen.basic.test.js` |
**Degraded-state convention:** missing keys are **absent**, never set to `null`. The `http request` consumer treats absent headers/payload fields as defaults.
## Port 1 (InfluxDB telemetry)
dashboardAPI emits **nothing** on Port 1 by design — it has no measurements, no tick loop, no telemetry. Verified by absence: no `formatForInflux` import, no Port 1 wires in `examples/`.
## Port 2 (registration / control plumbing)
dashboardAPI is a **sink** for `child.register` messages, not a source — it does not register itself with any parent. Nothing emitted on Port 2.
## Structured log outputs
| Event | Level | Triggered by | Fields | Test |
|---|---|---|---|---|
| `regen-emitted` | info | successful composition (auto or manual) | `event`, `trigger`, `dashboardApiId`, `childId`, `dashboardCount` | `test/basic/slice43-output-manifest.basic.test.js` |
| `regen-skipped` | info | diff predicate says subtree unchanged | `event`, `outcome: 'no-diff'`, `trigger: 'child.register'`, `dashboardApiId`, `childId`, `subtreeSize` | `test/basic/slice43-output-manifest.basic.test.js` |
| `manual-regen-requested` | info | `regenerate-dashboard` topic received | `event`, `trigger: 'manual'`, `dashboardApiId`, `cachedChildCount` | `test/basic/slice41-manual-regen.basic.test.js` |
| `parent-panels-deduped` | debug | no-data-duplication filter removed root panels | `event`, `before`, `after`, `rootTitle` | _covered by composition tests in slice39_ |
| `flows:started` | debug | Node-RED runtime emits flows:started | `event: 'flows:started'`, `type`, `diff` (count summary) | _covered by predicate tests in slice36_ |
## specificClass return shapes
| Method | Return shape | Populated states | Degraded states | Test |
|---|---|---|---|---|
| `buildDashboard(opts)` | `{ dashboard, uid, title, softwareType, nodeId, measurementName, bucket }` or `null`; `measurementName` mirrors `outputUtils.formatMsg` (`general.name` \|\| `<softwareType>_<id>`) so the dashboard `_measurement` var matches the telemetry series; `bucket` is the resolved Influx bucket | success (name set + name empty/fallback) | `null` when no template for softwareType | `test/basic/slice43-output-manifest.basic.test.js`, `test/basic/slice46-measurement-name-parity.basic.test.js` |
| `generateDashboardsForGraph(root)` | flat pre-order array of `buildDashboard` results (root first, then full descendant subtree); per-parent dedup + links applied at every level; machineGroup roots additionally get per-pump fan-out panels injected (see below) | 0..N children, 3-level tree, diamond, cycle | empty array when root config missing | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js`, `test/basic/slice44-recursive-discovery.basic.test.js` |
| `_injectMachineGroupPumpPanels(parentDash, children)` | mutates an MGC dashboard in place: replaces the static Total Flow/Power panels with 3 timeseries panels (Pump % Control, Pump Predicted Flow vs Demand, Pump Predicted Power) whose queries are generated from the child-pump measurement names. Panels carry `meta.emittedFields: []` so they survive the dedup pass | MGC with ≥1 rotatingMachine child | no-op for non-MGC dashboards or MGC with zero pump children (static totals retained) | `test/basic/slice47-mgc-pump-panels.basic.test.js` |
| `subtreeChanged(diff, ids)` | boolean | id-in-diff, no-id-in-diff | null diff → true (cold start) | `test/basic/slice36-diff-predicate.basic.test.js` |
| `subtreeIdsFor(myId, child)` | Set\<string\> | myId + every id in the child's full subtree (recurses all levels, cycle-safe) | myId only when child has no children | `test/basic/slice36-diff-predicate.basic.test.js`, `test/basic/slice44-recursive-discovery.basic.test.js` |
| `collectEmittedFields(dashboard)` | Set\<string\> | populated dashboard | empty set for `null`/`{}`/`{panels:[]}` | `test/basic/slice37-emitted-fields.basic.test.js` |
| `cachedChildSources()` | array of child sources | 0..N cached | empty after construction | `test/basic/slice41-manual-regen.basic.test.js` |
## Anti-patterns enforced
- ❌ Emitting `{payload: null}``handlers.emitDashboardsFor` always builds `payload: { dashboard, overwrite, ... }`. Verified.
- ❌ Mixing absent vs null for optional fields — `folderUid` / `folderId` are **absent** when unconfigured, never `null`. Verified.
- ❌ Per-call token stamping — token is set on `headers.Authorization` when configured; absent when not. No empty-string sentinel.
- ❌ Tab id over-triggering in diff predicate — predicate only matches against dashboardAPI's own id + child + grandchildren, never tab ids. Verified.
## Migration plan applied
This manifest is created together with slice #43 — the new outputs added in slices #34#42 are documented here. Other EVOLV nodes still need their own manifests; tracked in `IMPROVEMENTS_BACKLOG.md`.

View File

@@ -32,14 +32,14 @@ test('recordChild caches child source by id; subsequent ones replace by id', ()
assert.equal(api.cachedChildSources().length, 2); assert.equal(api.cachedChildSources().length, 2);
}); });
test('regenerate-dashboard with no cached children is a no-op (no msgs emitted)', () => { test('regenerate-dashboard with no cached children is a no-op (no msgs emitted)', async () => {
const api = new DashboardApi({}); const api = new DashboardApi({});
const sends = []; const sends = [];
handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, makeCtx(sends)); await handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, makeCtx(sends));
assert.equal(sends.length, 0); assert.equal(sends.length, 0);
}); });
test('regenerate-dashboard re-emits for each cached child, bypassing diff', () => { test('regenerate-dashboard re-emits for each cached child, bypassing diff', async () => {
const api = new DashboardApi({}); const api = new DashboardApi({});
// Pre-populate cache as if two children had registered. // Pre-populate cache as if two children had registered.
api.recordChild(makeChildPayload('m-1')); api.recordChild(makeChildPayload('m-1'));
@@ -50,18 +50,18 @@ test('regenerate-dashboard re-emits for each cached child, bypassing diff', () =
api.lastFlowsStartedDiff = { added: [], changed: [], removed: [], rewired: [] }; api.lastFlowsStartedDiff = { added: [], changed: [], removed: [], rewired: [] };
const sends = []; const sends = [];
handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, makeCtx(sends)); await handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, makeCtx(sends));
// Each child yields at least one dashboard message (the root for the child's view). // Each child yields at least one dashboard message (the root for the child's view).
assert.ok(sends.length >= 2, `expected ≥2 emitted msgs, got ${sends.length}`); assert.ok(sends.length >= 2, `expected ≥2 emitted msgs, got ${sends.length}`);
// Every emitted msg carries trigger: 'manual' in meta. // Every emitted msg carries trigger: 'manual' in meta.
for (const m of sends) assert.equal(m.meta?.trigger, 'manual'); for (const m of sends) assert.equal(m.meta?.trigger, 'manual');
}); });
test('child.register stamps trigger: child.register in emitted msg meta', () => { test('child.register stamps trigger: child.register in emitted msg meta', async () => {
const api = new DashboardApi({}); const api = new DashboardApi({});
api.lastFlowsStartedDiff = null; // cold-start → always regen api.lastFlowsStartedDiff = null; // cold-start → always regen
const sends = []; const sends = [];
handlers.registerChild(api, { topic: 'child.register', payload: makeChildPayload('m-3') }, makeCtx(sends)); await handlers.registerChild(api, { topic: 'child.register', payload: makeChildPayload('m-3') }, makeCtx(sends));
assert.ok(sends.length >= 1); assert.ok(sends.length >= 1);
for (const m of sends) assert.equal(m.meta?.trigger, 'child.register'); for (const m of sends) assert.equal(m.meta?.trigger, 'child.register');
}); });

View File

@@ -0,0 +1,146 @@
'use strict';
// Output-coverage tests per .claude/rules/output-coverage.md and
// test/_output-manifest.md. Every output is exercised in both populated
// and degraded states.
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
const handlers = require('../../src/commands/handlers.js');
function makeChild(id, name = id, softwareType = 'measurement') {
return {
config: {
general: { id, name },
functionality: { softwareType, positionVsParent: 'downstream' },
},
};
}
function makeCtx(nodeId = 'dApi-1') {
const sends = [];
const logs = [];
return {
sends,
logs,
ctx: {
node: { id: nodeId },
RED: { nodes: { getNode: () => null } },
send: (m) => sends.push(m),
logger: null,
},
};
}
// ── Port 0 message shape: populated ────────────────────────────────────
test('Port 0 emit has all required keys when token + folderUid configured', async () => {
const api = new DashboardApi({
grafanaConnector: { protocol: 'http', host: 'grafana', port: 3000, bearerToken: 'tok', folderUid: 'rnd-folder' },
});
api.lastFlowsStartedDiff = null; // cold start
const { sends, ctx } = makeCtx();
await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-1', 'FT-001') }, ctx);
assert.ok(sends.length >= 1);
const m = sends[0];
assert.equal(m.topic, 'create');
assert.equal(m.method, 'POST');
assert.equal(m.headers['Accept'], 'application/json');
assert.equal(m.headers['Content-Type'], 'application/json');
assert.equal(m.headers.Authorization, 'Bearer tok');
assert.match(m.url, /^http:\/\/grafana:3000\/api\/dashboards\/db$/);
assert.equal(m.payload.overwrite, true);
assert.ok(m.payload.dashboard, 'dashboard JSON present');
assert.equal(m.payload.folderUid, 'rnd-folder');
// meta
assert.equal(m.meta.nodeId, 'm-1');
assert.equal(m.meta.softwareType, 'measurement');
assert.equal(typeof m.meta.uid, 'string');
assert.equal(m.meta.title, 'FT-001');
assert.equal(m.meta.trigger, 'child.register');
});
// ── Port 0 degraded: token absent, folderUid absent ───────────────────
test('Port 0 emit omits Authorization header when no bearerToken configured', async () => {
const api = new DashboardApi({}); // no creds
api.lastFlowsStartedDiff = null;
const { sends, ctx } = makeCtx();
await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-2') }, ctx);
const m = sends[0];
assert.equal(m.headers.Authorization, undefined,
'Authorization should be absent (not empty string, not null)');
assert.equal(m.payload.folderUid, undefined,
'folderUid should be absent when empty');
assert.equal('folderId' in m.payload, false,
'folderId should also be absent (not 0)');
});
// ── Port 0 degraded: no template for softwareType ─────────────────────
test('Port 0 emits no message when child softwareType has no template', async () => {
const api = new DashboardApi({});
api.lastFlowsStartedDiff = null;
const { sends, ctx } = makeCtx();
// 'nonexistent' has no config/<>.json file
await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-3', 'm-3', 'nonexistent') }, ctx);
assert.equal(sends.length, 0, 'no upsert message should be emitted when template missing');
});
// ── Diff-skip path: no emission, logged outcome:no-diff ───────────────
test('Diff-skip suppresses Port 0 emission AND records the skip in source.logger', async () => {
const api = new DashboardApi({});
// Set diff so the predicate returns false (no overlap with subtree).
api.lastFlowsStartedDiff = { added: ['unrelated'], changed: [], removed: [], rewired: [] };
// Stub logger to capture
const captured = [];
api.logger = { info: (e) => captured.push(e), debug: () => {} };
const { sends, ctx } = makeCtx('dApi-1');
await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-4') }, ctx);
assert.equal(sends.length, 0, 'no upsert emitted when subtree unchanged');
const skipLog = captured.find((e) => e.event === 'regen-skipped');
assert.ok(skipLog, 'skip log emitted');
assert.equal(skipLog.outcome, 'no-diff');
assert.equal(skipLog.trigger, 'child.register');
assert.equal(skipLog.dashboardApiId, 'dApi-1');
assert.equal(skipLog.childId, 'm-4');
});
// ── Successful regen logs structured fields per N-4 ───────────────────
test('Successful regen logs event=regen-emitted with N-4 fields', async () => {
const api = new DashboardApi({});
api.lastFlowsStartedDiff = null; // cold start → always regen
const captured = [];
api.logger = { info: (e) => captured.push(e), debug: () => {} };
const { ctx } = makeCtx('dApi-1');
await handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-5') }, ctx);
const emitLog = captured.find((e) => e.event === 'regen-emitted');
assert.ok(emitLog, 'regen-emitted log present');
assert.equal(emitLog.trigger, 'child.register');
assert.equal(emitLog.dashboardApiId, 'dApi-1');
assert.equal(emitLog.childId, 'm-5');
assert.equal(typeof emitLog.dashboardCount, 'number');
});
// ── Manual regen logs manual-regen-requested + emits with trigger:manual ─
test('Manual regen logs manual-regen-requested and stamps trigger=manual', async () => {
const api = new DashboardApi({});
api.recordChild(makeChild('m-6'));
const captured = [];
api.logger = { info: (e) => captured.push(e), debug: () => {} };
const { sends, ctx } = makeCtx();
await handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, ctx);
const reqLog = captured.find((e) => e.event === 'manual-regen-requested');
assert.ok(reqLog, 'manual-regen-requested log present');
assert.equal(reqLog.cachedChildCount, 1);
if (sends.length > 0) {
assert.equal(sends[0].meta.trigger, 'manual');
}
});

View File

@@ -0,0 +1,104 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
// Build a source node with an optional registered-child Map. `children` is an
// array of source nodes; each is wrapped in the { child, position, softwareType }
// entry shape that childRegistrationUtils.registeredChildren uses at runtime.
function makeNode(id, softwareType, children = [], positionVsParent = 'downstream') {
const map = new Map();
for (const c of children) {
map.set(c.config.general.id, {
child: c,
softwareType: c.config.functionality.softwareType,
position: c.config.functionality.positionVsParent || 'downstream',
});
}
return {
config: {
general: { id, name: id },
functionality: { softwareType, positionVsParent },
},
childRegistrationUtils: { registeredChildren: map },
};
}
test('recurses a 3-level tree from a single wired root', () => {
const api = new DashboardApi({});
// dashboardapi(root) -> machineGroup(child) -> machine(grandchild)
const grandchild = makeNode('rm-1', 'machine');
const child = makeNode('mgc-1', 'machineGroupControl', [grandchild]);
const root = makeNode('ps-1', 'pumpingStation', [child], 'atequipment');
const dashboards = api.generateDashboardsForGraph(root);
const ids = dashboards.map((d) => d.nodeId);
assert.deepEqual(ids, ['ps-1', 'mgc-1', 'rm-1'], 'pre-order: root, child, grandchild');
assert.equal(dashboards[0].nodeId, 'ps-1', 'root composed first');
});
test('each parent links only to its own direct children (per-level links)', () => {
const api = new DashboardApi({});
const grandchild = makeNode('rm-1', 'machine');
const child = makeNode('mgc-1', 'machineGroupControl', [grandchild]);
const root = makeNode('ps-1', 'pumpingStation', [child], 'atequipment');
const dashboards = api.generateDashboardsForGraph(root);
const byId = Object.fromEntries(dashboards.map((d) => [d.nodeId, d.dashboard]));
assert.equal(byId['ps-1'].links.length, 1, 'root links to its one direct child');
assert.equal(byId['ps-1'].links[0].title, 'mgc-1');
assert.equal(byId['mgc-1'].links.length, 1, 'child links to its one grandchild');
assert.equal(byId['mgc-1'].links[0].title, 'rm-1');
assert.ok(!byId['rm-1'].links || byId['rm-1'].links.length === 0, 'leaf has no child links');
});
test('cycle protection: a node reachable twice is composed once', () => {
const api = new DashboardApi({});
const a = makeNode('a', 'pumpingStation', [], 'atequipment');
const b = makeNode('b', 'machineGroupControl');
// wire a -> b and b -> a (cycle)
a.childRegistrationUtils.registeredChildren.set('b', { child: b, softwareType: 'machineGroupControl', position: 'downstream' });
b.childRegistrationUtils.registeredChildren.set('a', { child: a, softwareType: 'pumpingStation', position: 'downstream' });
const dashboards = api.generateDashboardsForGraph(a);
const ids = dashboards.map((d) => d.nodeId).sort();
assert.deepEqual(ids, ['a', 'b'], 'each node composed exactly once despite the cycle');
});
test('diamond topology: shared descendant composed once', () => {
const api = new DashboardApi({});
const shared = makeNode('shared', 'machine');
const left = makeNode('left', 'machineGroupControl', [shared]);
const right = makeNode('right', 'machineGroupControl', [shared]);
const root = makeNode('root', 'pumpingStation', [left, right], 'atequipment');
const dashboards = api.generateDashboardsForGraph(root);
const sharedCount = dashboards.filter((d) => d.nodeId === 'shared').length;
assert.equal(sharedCount, 1, 'shared grandchild gets a single dashboard');
});
test('subtreeIdsFor recurses the full subtree (great-grandchildren included)', () => {
const api = new DashboardApi({});
const ggc = makeNode('ggc-1', 'measurement');
const gc = makeNode('gc-1', 'machine', [ggc]);
const child = makeNode('child-1', 'machineGroupControl', [gc]);
const ids = api.subtreeIdsFor('dApi-1', child);
assert.ok(ids.has('dApi-1') && ids.has('child-1') && ids.has('gc-1') && ids.has('ggc-1'));
assert.equal(ids.size, 4, 'dashboardAPI + child + grandchild + great-grandchild');
});
test('includeChildren:false composes only the root (no recursion)', () => {
const api = new DashboardApi({});
const grandchild = makeNode('rm-1', 'machine');
const child = makeNode('mgc-1', 'machineGroupControl', [grandchild]);
const root = makeNode('ps-1', 'pumpingStation', [child], 'atequipment');
const dashboards = api.generateDashboardsForGraph(root, { includeChildren: false });
assert.equal(dashboards.length, 1);
assert.equal(dashboards[0].nodeId, 'ps-1');
});

View File

@@ -0,0 +1,50 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
// softwareType (as reported at runtime, lowercased) -> the template that must resolve.
const CASES = [
['rotatingmachine', 'machine.json'],
['machinegroupcontrol', 'machineGroup.json'],
['pumpingstation', 'pumpingStation.json'],
['valvegroupcontrol', 'valveGroupControl.json'],
['diffuser', 'aeration.json'],
['measurement', 'measurement.json'],
['reactor', 'reactor.json'],
['settler', 'settler.json'],
['valve', 'valve.json'],
['monster', 'monster.json'],
];
for (const [softwareType, file] of CASES) {
test(`softwareType '${softwareType}' resolves to ${file}`, () => {
const api = new DashboardApi({});
const resolved = api._templateFileForSoftwareType(softwareType);
assert.ok(resolved, `expected a template path for ${softwareType}`);
assert.ok(resolved.endsWith(file), `expected ${file}, got ${resolved}`);
});
}
test('resolution is case-insensitive (camelCase softwareType still resolves)', () => {
const api = new DashboardApi({});
assert.ok(api._templateFileForSoftwareType('rotatingMachine').endsWith('machine.json'));
assert.ok(api._templateFileForSoftwareType('machineGroupControl').endsWith('machineGroup.json'));
});
test('rotatingmachine now builds a dashboard (was: no template found)', () => {
const api = new DashboardApi({});
const built = api.buildDashboard({
nodeConfig: { general: { id: 'rm-1', name: 'Pump A' }, functionality: { softwareType: 'rotatingmachine' } },
positionVsParent: 'downstream',
});
assert.ok(built, 'expected a built dashboard, not null');
assert.equal(built.softwareType, 'rotatingmachine');
});
test('unknown softwareType still returns null (no template)', () => {
const api = new DashboardApi({});
assert.equal(api._templateFileForSoftwareType('totally-unknown-type'), null);
});

View File

@@ -0,0 +1,62 @@
// The dashboard's `_measurement` templating var MUST equal the InfluxDB
// measurement name that outputUtils.formatMsg writes telemetry under, or every
// panel queries a non-existent series and renders blank.
//
// outputUtils convention (generalFunctions/src/helper/outputUtils.js):
// measurement = config.general.name || `${softwareType}_${config.general.id}`
//
// buildDashboard must mirror it exactly.
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass');
function makeApi() {
return new DashboardApi({
general: { name: 'dapi', logging: { enabled: false, logLevel: 'error' } },
grafanaConnector: { protocol: 'http', host: 'localhost', port: 3000, bearerToken: '' },
});
}
function measurementVar(dash) {
return dash.dashboard.templating.list.find((v) => v.name === 'measurement').current.value;
}
test('measurement var uses general.name when set (matches outputUtils)', () => {
const api = makeApi();
const dash = api.buildDashboard({
nodeConfig: {
general: { id: '248ba213d44df5b9', name: 'pumpingStation' },
functionality: { softwareType: 'pumpingstation' },
},
positionVsParent: 'atequipment',
});
assert.equal(dash.measurementName, 'pumpingStation');
assert.equal(measurementVar(dash), 'pumpingStation');
});
test('measurement var falls back to <softwareType>_<id> when name is empty', () => {
const api = makeApi();
const dash = api.buildDashboard({
nodeConfig: {
general: { id: '693ebd559017d39f', name: '' },
functionality: { softwareType: 'rotatingmachine' },
},
positionVsParent: 'atequipment',
});
assert.equal(dash.measurementName, 'rotatingmachine_693ebd559017d39f');
assert.equal(measurementVar(dash), 'rotatingmachine_693ebd559017d39f');
});
test('fallback id segment is the node id, not the title', () => {
const api = makeApi();
const dash = api.buildDashboard({
nodeConfig: {
general: { id: 'abc123' },
functionality: { softwareType: 'measurement' },
},
positionVsParent: 'upstream',
});
assert.equal(dash.measurementName, 'measurement_abc123');
});

View File

@@ -0,0 +1,156 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
// Build an MGC root with N rotatingMachine children, compose the graph, and
// return the MGC dashboard (results[0]).
function composeMgcWith(pumpDefs) {
const api = new DashboardApi({});
const entries = pumpDefs.map((p) => [p.id, {
child: { config: { general: { id: p.id, name: p.name }, functionality: { softwareType: p.softwareType || 'machine', positionVsParent: 'downstream' } } },
position: 'downstream',
}]);
const root = {
config: { general: { id: 'mgc-1', name: 'MGC' }, functionality: { softwareType: 'machineGroupControl', positionVsParent: 'atequipment' } },
childRegistrationUtils: { registeredChildren: new Map(entries) },
};
return { api, dash: api.generateDashboardsForGraph(root)[0].dashboard };
}
const PUMPS = [
{ id: 'pump-a', name: 'Pump A' },
{ id: 'pump-b', name: 'Pump B' },
];
test('MGC dashboard gains the three pump fan-out panels', () => {
const { dash } = composeMgcWith(PUMPS);
const titles = dash.panels.filter((p) => p.type === 'timeseries').map((p) => p.title);
assert.ok(titles.includes('Pump % Control'), 'missing % control panel');
assert.ok(titles.includes('Pump Predicted Flow vs Demand'), 'missing flow panel');
assert.ok(titles.includes('Pump Predicted Power'), 'missing power panel');
});
test('static group-total panels are replaced by the richer fan-out panels', () => {
const { dash } = composeMgcWith(PUMPS);
const titles = dash.panels.map((p) => p.title);
assert.ok(!titles.includes('Total Flow'), 'static Total Flow should be removed');
assert.ok(!titles.includes('Total Power'), 'static Total Power should be removed');
});
test('% control query targets every pump measurement; ctrl is already percent (no scaling)', () => {
const { dash } = composeMgcWith(PUMPS);
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
const q = panel.targets[0].query;
assert.match(q, /r\._measurement == "Pump A"/);
assert.match(q, /r\._measurement == "Pump B"/);
assert.match(q, /r\._field == "ctrl"/);
assert.ok(!/_value \* 100/.test(q), 'ctrl is 0..100 already — must NOT be ×100 scaled');
assert.equal(panel.fieldConfig.defaults.unit, 'percent');
});
test('% control plots both realized position and commanded setpoint per pump', () => {
const { dash } = composeMgcWith(PUMPS);
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
const realized = panel.targets.find((t) => /r\._field == "ctrl"/.test(t.query));
const setpoint = panel.targets.find((t) => /ctrl\\\.predicted\\\.atequipment/.test(t.query));
assert.ok(realized, 'missing realized-position (ctrl) series');
assert.ok(setpoint, 'missing commanded-setpoint (ctrl.predicted.atequipment) series');
// childId varies per pump → setpoint must be a regex (=~) prefix match.
assert.match(setpoint.query, /r\._field =~ \/\^ctrl\\\.predicted\\\.atequipment\\\.\//);
assert.ok(!/\.default/.test(setpoint.query), 'must not hardcode childId .default');
// Legend disambiguation: each series suffixes its _measurement.
assert.match(realized.query, /\(realized\)/);
assert.match(setpoint.query, /\(setpoint\)/);
});
test('setpoint series is drawn dashed to distinguish it from realized', () => {
const { dash } = composeMgcWith(PUMPS);
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
const ov = panel.fieldConfig.overrides.find((o) => /setpoint/.test(o.matcher.options));
assert.ok(ov, 'missing dashed override for setpoint series');
assert.equal(ov.matcher.id, 'byRegexp');
const lineStyle = ov.properties.find((p) => p.id === 'custom.lineStyle')?.value;
assert.equal(lineStyle?.fill, 'dash', 'setpoint must be dashed');
});
test('per-pump flow/power match the position prefix (childId varies per pump)', () => {
const { dash } = composeMgcWith(PUMPS);
const flowQ = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand').targets[0].query;
const powerQ = dash.panels.find((p) => p.title === 'Pump Predicted Power').targets[0].query;
// Regex field match (=~), not an exact `.default` key, so it catches
// `flow.predicted.atequipment.<pumpId>` whatever the childId is.
assert.match(flowQ, /r\._field =~ \/\^flow\\\.predicted\\\.atequipment\\\.\//);
assert.match(powerQ, /r\._field =~ \/\^power\\\.predicted\\\.atequipment\\\.\//);
assert.ok(!/\.default/.test(flowQ), 'must not hardcode childId .default');
});
test('measurement name falls back to <softwareType>_<id> when name is unset', () => {
const { dash } = composeMgcWith([{ id: 'p9', softwareType: 'rotatingmachine' }]);
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
assert.match(panel.targets[0].query, /r\._measurement == "rotatingmachine_p9"/);
});
test('flow panel folds in total flow, demand setpoint, demand %, and per-pump flow', () => {
const { dash } = composeMgcWith(PUMPS);
const panel = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand');
const queries = panel.targets.map((t) => t.query).join('\n');
assert.match(queries, /flow\\\.predicted\\\.atequipment/, 'per-pump flow field');
assert.match(queries, /atEquipment_predicted_flow/, 'group total flow field');
assert.match(queries, /demandFlow/, 'resolved flow setpoint field');
assert.match(queries, /demandPct/, 'demand percent field');
});
test('flow capacity envelope is drawn as dashed min/max lines', () => {
const { dash } = composeMgcWith(PUMPS);
const panel = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand');
const byName = Object.fromEntries(
panel.fieldConfig.overrides.map((o) => [o.matcher.options, o.properties]));
for (const cap of ['flowCapacityMin', 'flowCapacityMax']) {
const props = byName[cap];
assert.ok(props, `missing override for ${cap}`);
const lineStyle = props.find((p) => p.id === 'custom.lineStyle')?.value;
assert.equal(lineStyle?.fill, 'dash', `${cap} must be dashed`);
}
});
test('demand % is placed on a secondary (right) axis in percent', () => {
const { dash } = composeMgcWith(PUMPS);
const panel = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand');
const props = panel.fieldConfig.overrides.find((o) => o.matcher.options === 'demandPct')?.properties || [];
assert.equal(props.find((p) => p.id === 'unit')?.value, 'percent');
assert.equal(props.find((p) => p.id === 'custom.axisPlacement')?.value, 'right');
});
test('power panel folds total power in with per-pump power', () => {
const { dash } = composeMgcWith(PUMPS);
const panel = dash.panels.find((p) => p.title === 'Pump Predicted Power');
const queries = panel.targets.map((t) => t.query).join('\n');
assert.match(queries, /power\\\.predicted\\\.atequipment/, 'per-pump power field');
assert.match(queries, /atEquipment_predicted_power/, 'group total power field');
});
test('injected panels are exempt from the no-duplication dedup (empty emittedFields)', () => {
const { dash } = composeMgcWith(PUMPS);
const dynamic = dash.panels.filter((p) => p?.meta?.dynamic === 'mgc-pump-fanout');
assert.equal(dynamic.length, 3);
for (const p of dynamic) assert.deepEqual(p.meta.emittedFields, []);
});
test('a machineGroup with no pump children keeps the static template panels', () => {
const { dash } = composeMgcWith([
{ id: 'm1', name: 'Meter', softwareType: 'measurement' },
]);
const titles = dash.panels.map((p) => p.title);
assert.ok(titles.includes('Total Flow'), 'static totals must remain when no pumps');
assert.ok(!titles.includes('Pump % Control'), 'no fan-out panels without pumps');
});
test('injected panels reuse the dashboard influxdb datasource uid', () => {
const { dash } = composeMgcWith(PUMPS);
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
assert.equal(panel.datasource.type, 'influxdb');
assert.equal(panel.datasource.uid, 'cdzg44tv250jkd');
});

View File

@@ -0,0 +1,100 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
const { registerChild } = require('../../src/commands/handlers.js');
// Minimal fetch double. `routes` maps `${method} ${pathname}` to a response
// descriptor { ok, status, body }. Records every call for assertions.
function makeFetch(routes) {
const calls = [];
const fetchImpl = async (url, opts = {}) => {
const method = opts.method || 'GET';
const { pathname } = new URL(url);
calls.push({ method, pathname, body: opts.body });
const r = routes[`${method} ${pathname}`];
if (!r) return { ok: false, status: 404, json: async () => ({ message: 'not found' }) };
if (typeof r === 'function') return r();
return { ok: r.ok ?? true, status: r.status ?? 200, json: async () => r.body };
};
fetchImpl.calls = calls;
return fetchImpl;
}
function api(grafanaConnector) {
return new DashboardApi({ grafanaConnector });
}
test('no folderTitle → returns configured folderUid without any fetch (legacy path)', async () => {
const a = api({ folderUid: 'pinned-uid' });
const fetchImpl = makeFetch({});
const uid = await a.resolveFolderUid({ fetchImpl });
assert.equal(uid, 'pinned-uid');
assert.equal(fetchImpl.calls.length, 0, 'must not call Grafana when no folderTitle is set');
});
test('folderTitle matches an existing folder (case-insensitive) → returns its uid', async () => {
const a = api({ folderTitle: 'EVOLV' });
const fetchImpl = makeFetch({
'GET /api/folders': { body: [{ title: 'Other', uid: 'x' }, { title: 'evolv', uid: 'bfncls6af0b9cb' }] },
});
const uid = await a.resolveFolderUid({ fetchImpl });
assert.equal(uid, 'bfncls6af0b9cb');
assert.equal(fetchImpl.calls.filter((c) => c.method === 'POST').length, 0, 'must not create when found');
});
test('resolution is cached → second call makes no further fetch', async () => {
const a = api({ folderTitle: 'EVOLV' });
const fetchImpl = makeFetch({ 'GET /api/folders': { body: [{ title: 'EVOLV', uid: 'u1' }] } });
await a.resolveFolderUid({ fetchImpl });
await a.resolveFolderUid({ fetchImpl });
assert.equal(fetchImpl.calls.length, 1, 'second resolve should hit the cache');
});
test('folder absent → creates it by name and returns the new uid', async () => {
const a = api({ folderTitle: 'EVOLV' });
const fetchImpl = makeFetch({
'GET /api/folders': { body: [{ title: 'Other', uid: 'x' }] },
'POST /api/folders': { status: 200, body: { uid: 'created-uid', title: 'EVOLV' } },
});
const uid = await a.resolveFolderUid({ fetchImpl });
assert.equal(uid, 'created-uid');
const post = fetchImpl.calls.find((c) => c.method === 'POST');
assert.equal(JSON.parse(post.body).title, 'EVOLV');
});
test('fetch throws → falls back to configured folderUid (never worse than pinned)', async () => {
const a = api({ folderTitle: 'EVOLV', folderUid: 'fallback-uid' });
const fetchImpl = async () => { throw new Error('ECONNREFUSED'); };
const uid = await a.resolveFolderUid({ fetchImpl });
assert.equal(uid, 'fallback-uid');
});
test('no fetch implementation available → falls back to configured folderUid', async () => {
const a = api({ folderTitle: 'EVOLV', folderUid: 'fallback-uid' });
// Pass an explicit non-function (not undefined, which would trigger the
// globalThis.fetch default) to exercise the "no fetch available" branch.
const uid = await a.resolveFolderUid({ fetchImpl: null });
assert.equal(uid, 'fallback-uid');
});
test('emit path stamps the resolved folderUid onto every upsert payload', async () => {
const a = api({ folderTitle: 'EVOLV' });
// Force a deterministic resolution without standing up fetch.
a.resolveFolderUid = async () => 'resolved-folder-uid';
const childSource = {
config: { general: { id: 'm1', name: 'Level' }, functionality: { softwareType: 'measurement' } },
};
const sent = [];
const ctx = { node: { id: 'dapi' }, send: (m) => sent.push(m) };
await registerChild(a, { payload: childSource }, ctx);
assert.ok(sent.length >= 1, 'should emit at least one create');
for (const m of sent) {
assert.equal(m.topic, 'create');
assert.equal(m.payload.folderUid, 'resolved-folder-uid');
}
});

View File

@@ -46,7 +46,8 @@ describe('DashboardApi specificClass', () => {
const measurement = templ.find((v) => v.name === 'measurement'); const measurement = templ.find((v) => v.name === 'measurement');
const bucket = templ.find((v) => v.name === 'bucket'); const bucket = templ.find((v) => v.name === 'bucket');
expect(measurement.current.value).toBe('measurement_m-1'); // measurement var must mirror outputUtils: general.name when set.
expect(measurement.current.value).toBe('PT-1');
expect(bucket.current.value).toBe('lvl3'); expect(bucket.current.value).toBe('lvl3');
}); });