17 Commits

Author SHA1 Message Date
znetsixe
533f74fe7e chore(dashboardAPI): bigger rim/floor labels with guaranteed clearance
Rim ('rim (X m)') and floor ('floor (0.00 m)') captions now use the same
size 14 font as the threshold labels (was size 10 — visibly smaller and
hard to read on a high-resolution dashboard). To make room for the larger
text while ensuring rim/floor can NEVER overlap the topmost (overflow) or
bottommost (outflow) threshold lines at any basin geometry, the tank's
vertical margins are bumped from 40 px to 48 px each:

  TANK_TOP: 40 -> 48,  TANK_BOT: 720 -> 712,  TANK_H: 680 -> 664

Rim placement: top:1, bottom:95 (4 % tall band at the top, spanning 7.6 -> 38 px).
Floor placement: top:95, bottom:1 (4 % tall band at the bottom).
The topmost threshold line (overflow at max basinHeight) sits at TANK_TOP=48 px,
leaving 10 px clearance above the line. Same for the bottommost (outflow) line.

Color: #8a8a8a -> #6a6a6a (slightly darker so it reads at the bigger size).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:59:58 +02:00
znetsixe
a16f526964 chore(dashboardAPI): enforce min visual gap between threshold lines
User-visible problem: with the basin config dryRunThresholdPercent=2 (so
dryRunLevel ≈ outflowLevel) and highVolumeSafetyThresholdPercent=98 (so
highSafetyLevel ≈ overflowLevel), two pairs of threshold lines sat right
on top of each other in the tank visual, leaving no room between them for
their labels. The 'BELOW' fallback in the label algorithm couldn't fit
either, so labels ended up crossing lines.

Fix: enforce a minimum 28 px visual gap between adjacent threshold lines
inside the tank (≈3.7 % of the 760-tall reference frame, > LABEL_H + 2).
Lines closer than that get spread apart while preserving order. If the
stack would push the lowest line past the tank floor, the whole stack
shifts up to fit. Slight geometric distortion is accepted — the tank
visual conveys ordering and zone structure, not exact-scale level
measurement; numeric values are still rendered next to each line.

Result: at any basin geometry, labels sit cleanly above their line with
no overlap, no label-on-line collision, and no fallback to a 'stacked'
position that crosses its own line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:52:19 +02:00
znetsixe
8afc6b9779 chore(dashboardAPI): center tank via canvas scale constraint + bigger labels
Per Grafana Canvas docs, the correct way to make elements stay centered
and stretch with panel size is to set 'constraint: { horizontal: scale,
vertical: scale }' on every element AND use margin-style placement
(top + bottom + left + right, all as percentages of the panel) instead
of pixel-based 'top + left + width + height'.

This commit:
- Adds 'constraint: scale/scale' to every canvas element.
- Converts all placements to percentage margins. Hardcoded canvas
  geometry (tank, zones, threshold lines, header, footer) uses literal
  percentages; per-basin geometry (yp_*, ty_*, etc.) is precomputed in
  _templateVarsForNode and emitted as percent values from the substitution.
- Adds derived 'zb_*', 'yb_*', 'tyb_*' substitution vars for bottom
  margins of zones, lines, and labels respectively.
- Splits name/value labels left/right of tank centre with a visible gap
  between them (was touching) and bumps font size 11 -> 14 for readability.

Result: at any panel/viewport size the tank fills the card with equal
left/right margins (~2.5%) and equal top/bottom margins (~5.26%) for
rim/floor captions, no letterboxing or right-side padding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:22:11 +02:00
znetsixe
193f913eb1 chore(dashboardAPI): center tank vertically + handle floor-edge labels
Tank rectangle moved from top-aligned (top=20 in 760 frame) to vertically
centered (top=40, with 40 px top + 40 px bottom margins for the rim and
floor caption text). Header rim caption shifted to y=20, footer floor to
y=724, so both sit just outside the tank rect.

Label algorithm extended: when a label would normally go BELOW its line
but doing so would push it past the tank floor (which happens for very
small dryRunThresholdPercent — dryRunLevel sits right on outflowLevel,
both nearly at the basin floor), it falls back to stacking ABOVE the
previous label instead of extending into invisible space. This keeps
all 5 threshold labels inside the visible canvas area at the cost of a
slight visual overlap of the lowest label with its own line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:38:58 +02:00
znetsixe
41a20d4679 chore(dashboardAPI): center basin labels, position above/below lines
Threshold labels were sitting right on top of their lines (label center
at line_y - 8) and were right-aligned at the tank's right edge. They now:

- Sit clearly above the line (label bottom 6 px above) by default, or
  below the line (label top 6 px below) when an adjacent threshold is
  closer than 24 px (would crowd both labels above their lines). For
  the current basin config this puts overflowLevel + inflowLevel +
  dryRunLevel ABOVE their lines, and highSafety + outflowLevel BELOW.
- Are centered horizontally in the tank (name at left:115 width:95
  right-aligned, value at left:215 width:80 left-aligned) so the
  combined phrase "overflowLevel  3.22 m" reads as one centered string.

Value width 60 → 80 so 'mm'-formatted small-meter values don't wrap to
two lines. Footer floor moved to y:728 to keep clear of the BELOW labels
near the tank floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:32:45 +02:00
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
25 changed files with 2094 additions and 767 deletions

View File

@@ -150,7 +150,7 @@
"type": "influxdb",
"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"
},
{
@@ -159,7 +159,7 @@
"uid": "cdzg44tv250jkd"
},
"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"
},
{
@@ -168,7 +168,7 @@
"uid": "cdzg44tv250jkd"
},
"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"
}
],
@@ -282,7 +282,7 @@
"type": "influxdb",
"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"
}
],
@@ -351,7 +351,7 @@
"type": "influxdb",
"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"
}
],
@@ -420,7 +420,7 @@
"type": "influxdb",
"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"
}
],
@@ -489,7 +489,7 @@
"type": "influxdb",
"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"
}
],
@@ -558,7 +558,7 @@
"type": "influxdb",
"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"
}
],
@@ -627,7 +627,7 @@
"type": "influxdb",
"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"
}
],
@@ -702,7 +702,7 @@
"type": "influxdb",
"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"
}
],
@@ -777,7 +777,7 @@
"type": "influxdb",
"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"
}
],
@@ -872,7 +872,7 @@
"uid": "cdzg44tv250jkd"
},
"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"
}
],
@@ -940,7 +940,7 @@
"type": "influxdb",
"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"
}
],
@@ -1009,7 +1009,7 @@
"type": "influxdb",
"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"
}
],
@@ -1089,7 +1089,7 @@
"type": "influxdb",
"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"
}
],
@@ -1158,7 +1158,7 @@
"type": "influxdb",
"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"
}
],
@@ -1227,7 +1227,7 @@
"type": "influxdb",
"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"
}
],
@@ -1302,7 +1302,7 @@
"type": "influxdb",
"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"
}
],
@@ -1375,7 +1375,7 @@
"type": "influxdb",
"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"
}
],
@@ -1470,7 +1470,7 @@
"uid": "cdzg44tv250jkd"
},
"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"
}
],
@@ -1538,7 +1538,7 @@
"type": "influxdb",
"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"
}
],
@@ -1607,7 +1607,7 @@
"type": "influxdb",
"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"
}
],
@@ -1687,7 +1687,7 @@
"type": "influxdb",
"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"
}
],
@@ -1756,7 +1756,7 @@
"type": "influxdb",
"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"
}
],
@@ -1825,7 +1825,7 @@
"type": "influxdb",
"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"
}
],
@@ -1900,7 +1900,7 @@
"type": "influxdb",
"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"
}
],
@@ -1973,7 +1973,7 @@
"type": "influxdb",
"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"
}
],
@@ -2068,7 +2068,7 @@
"uid": "cdzg44tv250jkd"
},
"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"
}
],
@@ -2136,7 +2136,7 @@
"type": "influxdb",
"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"
}
],
@@ -2205,7 +2205,7 @@
"type": "influxdb",
"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"
}
],
@@ -2285,7 +2285,7 @@
"type": "influxdb",
"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"
}
],
@@ -2354,7 +2354,7 @@
"type": "influxdb",
"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"
}
],
@@ -2423,7 +2423,7 @@
"type": "influxdb",
"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"
}
],
@@ -2498,7 +2498,7 @@
"type": "influxdb",
"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"
}
],
@@ -2571,7 +2571,7 @@
"type": "influxdb",
"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"
}
],
@@ -2681,7 +2681,7 @@
"type": "influxdb",
"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"
},
{
@@ -2690,7 +2690,7 @@
"uid": "cdzg44tv250jkd"
},
"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"
}
],
@@ -2787,7 +2787,7 @@
"type": "influxdb",
"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"
}
],
@@ -2869,7 +2869,7 @@
"type": "influxdb",
"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"
},
{
@@ -2878,7 +2878,7 @@
"uid": "cdzg44tv250jkd"
},
"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"
}
],
@@ -2979,7 +2979,7 @@
"uid": "cdzg44tv250jkd"
},
"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"
},
{
@@ -2988,7 +2988,7 @@
"uid": "cdzg44tv250jkd"
},
"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"
}
],
@@ -3099,7 +3099,7 @@
"type": "influxdb",
"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"
},
{
@@ -3108,7 +3108,7 @@
"uid": "cdzg44tv250jkd"
},
"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"
}
],
@@ -3214,7 +3214,7 @@
"type": "influxdb",
"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"
}
],

View File

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

View File

@@ -88,14 +88,15 @@
"reduceOptions": {
"calcs": [
"lastNotNull"
]
],
"fields": "/.*/"
},
"colorMode": "value",
"graphMode": "none"
},
"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"
}
],
@@ -137,14 +138,15 @@
"reduceOptions": {
"calcs": [
"lastNotNull"
]
],
"fields": "/.*/"
},
"colorMode": "value",
"graphMode": "none"
},
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mode\")\n |> last()",
"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"
}
],
@@ -208,7 +210,7 @@
},
"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"
}
],
@@ -258,7 +260,7 @@
},
"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"
}
],
@@ -322,7 +324,7 @@
},
"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"
}
],
@@ -429,7 +431,7 @@
},
"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"
}
],
@@ -526,7 +528,7 @@
},
"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"
}
],
@@ -635,7 +637,7 @@
},
"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"
}
],
@@ -732,7 +734,7 @@
},
"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"
}
],
@@ -793,14 +795,15 @@
"reduceOptions": {
"calcs": [
"lastNotNull"
]
],
"fields": "/.*/"
},
"colorMode": "value",
"graphMode": "area"
},
"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"
}
],
@@ -857,7 +860,7 @@
},
"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"
}
],
@@ -914,7 +917,7 @@
},
"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"
}
],
@@ -971,7 +974,7 @@
},
"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"
}
],

View File

@@ -61,72 +61,22 @@
"reduceOptions": {
"calcs": [
"lastNotNull"
]
],
"fields": "/.*/"
},
"colorMode": "value",
"graphMode": "none"
},
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mode\")\n |> last()",
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"mode\")\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
"refId": "A"
}
],
"title": "Mode",
"type": "stat",
"meta": {
"emittedFields": [
"mode"
]
}
},
{
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "blue",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 1
},
"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"
]
"emittedFields": []
}
},
{
@@ -174,7 +124,7 @@
},
"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"
}
],
@@ -232,16 +182,14 @@
},
"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"
}
],
"title": "Rel Dist Peak",
"type": "stat",
"meta": {
"emittedFields": [
"relDistFromPeak"
]
"emittedFields": []
}
},
{
@@ -339,7 +287,7 @@
},
"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"
}
],
@@ -436,7 +384,7 @@
},
"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"
}
],

View File

@@ -25,7 +25,7 @@
"id": 2,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"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)",
"type": "stat"
@@ -37,7 +37,7 @@
"id": 3,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "showThresholdLabels": false, "showThresholdMarkers": true },
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"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",
"type": "gauge"
@@ -49,7 +49,7 @@
"id": 4,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"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",
"type": "timeseries"
@@ -62,7 +62,7 @@
"id": 6,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"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",
"type": "timeseries"
@@ -74,7 +74,7 @@
"id": 7,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" 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",
"type": "timeseries"

View File

@@ -3,7 +3,10 @@
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
@@ -17,14 +20,42 @@
"id": null,
"links": [],
"panels": [
{ "gridPos": { "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 },
"gridPos": {
"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,
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "/.*/"
},
"colorMode": "value",
"graphMode": "none"
},
"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"
}
],
@@ -32,14 +63,32 @@
"type": "stat"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] },
"gridPos": { "h": 9, "w": 16, "x": 8, "y": 1 },
"datasource": {
"type": "influxdb",
"uid": "cdzg44tv250jkd"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 16,
"x": 8,
"y": 1
},
"id": 3,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
}
},
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"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"
}
],
@@ -48,7 +97,11 @@
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "monster", "template"],
"tags": [
"EVOLV",
"monster",
"template"
],
"templating": {
"list": [
{
@@ -56,30 +109,62 @@
"type": "custom",
"label": "dbase",
"query": "cdzg44tv250jkd",
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false },
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }],
"current": {
"text": "cdzg44tv250jkd",
"value": "cdzg44tv250jkd",
"selected": false
},
"options": [
{
"text": "cdzg44tv250jkd",
"value": "cdzg44tv250jkd",
"selected": true
}
],
"hide": 2
},
{
"name": "measurement",
"type": "custom",
"query": "template",
"current": { "text": "template", "value": "template", "selected": false },
"options": [{ "text": "template", "value": "template", "selected": true }]
"current": {
"text": "template",
"value": "template",
"selected": false
},
"options": [
{
"text": "template",
"value": "template",
"selected": true
}
]
},
{
"name": "bucket",
"type": "custom",
"query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
"current": {
"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": "",
"title": "template",
"uid": null,
"version": 1
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@
protocol: { value: 'http' },
host: { value: 'localhost' },
port: { value: 3000 },
folderTitle: { value: '' },
folderUid: { value: '' },
defaultBucket: { value: '' },
},
@@ -47,7 +48,7 @@
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}`);
if (!element) return;
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%;" />
</div>
<div class="form-row">
<label for="node-input-folderTitle"><i class="fa fa-folder"></i> Grafana Folder</label>
<input type="text" id="node-input-folderTitle" placeholder="folder name e.g. EVOLV — resolved/created by name" style="width:70%;" />
</div>
<div class="form-row">
<label for="node-input-folderUid"><i class="fa fa-folder-open"></i> Grafana Folder UID</label>
<input type="text" id="node-input-folderUid" placeholder="optional — 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 class="form-row">

View File

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

View File

@@ -24,7 +24,7 @@ function resolveChildNode(childId, ctx) {
// Shared emit path used by both child.register (auto, deploy-driven) and
// 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, {
includeChildren: Boolean(msg.includeChildren ?? true),
});
@@ -34,6 +34,13 @@ function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
const token = source.config?.grafanaConnector?.bearerToken;
if (token) headers.Authorization = `Bearer ${token}`;
// Resolve the folder by name (creating it if missing) so a rebuilt Grafana's
// fresh folder uid never strands the upserts on a stale pinned uid. Falls
// back to the configured folderUid on any failure.
const folderUid = typeof source.resolveFolderUid === 'function'
? await source.resolveFolderUid()
: (source.config?.grafanaConnector?.folderUid || undefined);
for (const dash of dashboards) {
ctx.send({
...msg,
@@ -43,7 +50,7 @@ function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
headers,
payload: source.buildUpsertRequest({
dashboard: dash.dashboard,
folderUid: source.config?.grafanaConnector?.folderUid || undefined,
folderUid: folderUid || undefined,
overwrite: true,
}),
meta: {
@@ -74,7 +81,7 @@ function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
// payload's `diff` indicates that NEITHER the dashboardAPI itself NOR this
// child NOR its grandchildren changed, skip composition and log no-diff. The
// first call after startup (no cached diff yet) regenerates unconditionally.
function registerChild(source, msg, ctx) {
async function registerChild(source, msg, ctx) {
const childSource = resolveChildSource(msg.payload, ctx);
if (!childSource?.config) {
throw new Error('Missing or invalid child node');
@@ -99,13 +106,13 @@ function registerChild(source, msg, ctx) {
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,
// 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.
function regenerateDashboard(source, msg, ctx) {
async function regenerateDashboard(source, msg, ctx) {
const cached = source.cachedChildSources?.() || [];
if (source.logger?.info) {
source.logger.info({
@@ -116,7 +123,7 @@ function regenerateDashboard(source, msg, ctx) {
});
}
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',
port: Number(uiConfig.port || 3000),
bearerToken,
folderTitle: uiConfig.folderTitle || '',
folderUid: uiConfig.folderUid || '',
},
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',

View File

@@ -18,6 +18,28 @@ function slugify(input) {
.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) {
const pos = String(positionVsParent || '').toLowerCase();
if (pos === 'upstream') return 'lvl1';
@@ -25,6 +47,20 @@ function defaultBucketForPosition(positionVsParent) {
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) {
const list = dashboard?.templating?.list;
if (!Array.isArray(list)) return;
@@ -64,6 +100,12 @@ class DashboardApi {
host: config?.grafanaConnector?.host || 'localhost',
port: Number(config?.grafanaConnector?.port || 3000),
bearerToken: config?.grafanaConnector?.bearerToken || '',
// folderTitle is the durable way to target a folder: Grafana folder
// uids change whenever the instance is rebuilt, so a pinned folderUid
// goes stale (every upsert then 400s "folder not found"). When set, the
// uid is resolved (and the folder created if absent) by name at emit
// time. folderUid stays supported as an explicit override / fallback.
folderTitle: config?.grafanaConnector?.folderTitle || '',
folderUid: config?.grafanaConnector?.folderUid || '',
},
defaultBucket: config?.defaultBucket || '',
@@ -98,9 +140,9 @@ class DashboardApi {
_templateFileForSoftwareType(softwareType) {
const st = String(softwareType || '').trim();
const candidates = [
TEMPLATE_FILE_BY_SOFTWARE_TYPE[st.toLowerCase()],
`${st}.json`,
`${st.toLowerCase()}.json`,
st === 'machineGroupControl' ? 'machineGroup.json' : null,
].filter(Boolean);
for (const filename of candidates) {
@@ -112,13 +154,180 @@ class DashboardApi {
return null;
}
loadTemplate(softwareType) {
loadTemplate(softwareType, templateVars = null) {
const templatePath = this._templateFileForSoftwareType(softwareType);
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);
}
// Per-softwareType numeric vars baked into the template before JSON.parse.
// Today only pumpingStation needs this (basin geometry → bar-gauge thresholds
// and canvas y-positions). Other types return {} and skip substitution.
_templateVarsForNode(softwareType, nodeConfig) {
const st = String(softwareType || '').toLowerCase();
if (st !== 'pumpingstation') return {};
// configManager.buildConfig nests basin geometry under `basin.*` and
// safety percentages under `safety.*` (see generalFunctions/configManager).
const basin = nodeConfig?.basin || {};
const safety = nodeConfig?.safety || {};
const heightBasin = Number(basin.height) || 4;
const inflowLevel = Number(basin.inflowLevel) || 0;
const outflowLevel = Number(basin.outflowLevel) || 0;
const overflowLevel = Number(basin.overflowLevel) || heightBasin;
const dryRunPct = Number(safety.dryRunThresholdPercent) || 30;
const highPct = Number(safety.highVolumeSafetyThresholdPercent) || 90;
// Mirror specificClass._computeSafetyPoints derivation (pumpingStation).
const dryRunLevel = outflowLevel * (1 + dryRunPct / 100);
const highSafetyLevel = overflowLevel * (highPct / 100);
// Reference frame: 400 (logical w) x 760 (logical h) px. With every
// canvas element using `constraint: { horizontal: scale, vertical: scale }`,
// Grafana interprets placement values as PERCENTAGES of the panel size,
// not pixels — so the basin stretches to fill the card at any viewport
// and stays centered without letterboxing.
// Tank reference: rim at y=48px (6.32%), floor at y=712px (93.68%),
// centred vertically with 48px top/bottom margins. Margins are sized
// so the size-14 'rim (X m)' and 'floor (0.00 m)' captions fit with
// ~10 px clearance from the topmost/bottommost threshold line — labels
// can never collide with a line at any basin geometry.
const FRAME_W = 400, FRAME_H = 760;
const TANK_TOP = 48, TANK_BOT = 712, TANK_H = TANK_BOT - TANK_TOP;
const yp = (v) => +(v / FRAME_H * 100).toFixed(2);
const xp = (v) => +(v / FRAME_W * 100).toFixed(2);
const hp = (v) => +(v / FRAME_H * 100).toFixed(2);
const wp = (v) => +(v / FRAME_W * 100).toFixed(2);
const yFor = (v) => +(TANK_BOT - (v / heightBasin) * TANK_H).toFixed(2);
const tyFor = (yLine) => +(yLine - 8).toFixed(2); // centre 16px text on the line
let y_overflow = yFor(overflowLevel);
let y_highSafety = yFor(highSafetyLevel);
let y_inflow = yFor(inflowLevel);
let y_dryRun = yFor(dryRunLevel);
let y_outflow = yFor(outflowLevel);
// Enforce a minimum visual gap between adjacent threshold lines so labels
// can always sit cleanly between them — independent of how close the
// underlying physical thresholds are. Slight geometric distortion is
// acceptable: the tank visual conveys ORDERING and ZONE STRUCTURE, not
// exact-scale level measurement. Dashed/value labels carry the true
// numeric values.
const MIN_LINE_GAP = 28; // px (≈3.7% of 760-tall frame, > LABEL_H + 2)
const sorted = [
{ id: 'overflow', get: () => y_overflow, set: (v) => (y_overflow = v) },
{ id: 'highSafety', get: () => y_highSafety, set: (v) => (y_highSafety = v) },
{ id: 'inflow', get: () => y_inflow, set: (v) => (y_inflow = v) },
{ id: 'dryRun', get: () => y_dryRun, set: (v) => (y_dryRun = v) },
{ id: 'outflow', get: () => y_outflow, set: (v) => (y_outflow = v) },
].sort((a, b) => a.get() - b.get());
// Push down to enforce min gap (anchor: topmost line)
for (let i = 1; i < sorted.length; i++) {
const minY = sorted[i - 1].get() + MIN_LINE_GAP;
if (sorted[i].get() < minY) sorted[i].set(minY);
}
// If the last (lowest) line went past the floor, shift the whole stack up.
const overshoot = sorted[sorted.length - 1].get() - TANK_BOT;
if (overshoot > 0) {
for (const item of sorted) item.set(item.get() - overshoot);
}
// Label y-positions: labels sit either ABOVE or BELOW their threshold
// line, never on it. Each label is offset by ABOVE_OFFSET=22 px above
// its line by default (16 px tall label + 6 px clear above the line).
// If two thresholds are too close together for both labels to fit ABOVE
// their lines (label of the lower one would cross the upper line), the
// lower one's label flips BELOW its line instead. With the current
// basin (dryRun=2% means dryRunLevel sits right on outflowLevel; high-
// Safety=98% puts it just under overflowLevel) this naturally puts
// highSafety BELOW and outflow BELOW.
const LABEL_H = 16;
const ABOVE_OFFSET = 22; // label_top = line_y - 22 (6 px clear above line)
const BELOW_OFFSET = 6; // label_top = line_y + 6 (6 px clear below line)
const MIN_DIST_FOR_ABOVE = 24; // if distance to upper line < this, try below
const lines = [
{ id: 'overflow', line: y_overflow },
{ id: 'highSafety', line: y_highSafety },
{ id: 'inflow', line: y_inflow },
{ id: 'dryRun', line: y_dryRun },
{ id: 'outflow', line: y_outflow },
].sort((a, b) => a.line - b.line);
for (let i = 0; i < lines.length; i++) {
const prev = i > 0 ? lines[i - 1] : null;
const tooClose = prev && (lines[i].line - prev.line) < MIN_DIST_FOR_ABOVE;
if (tooClose) {
// Default to BELOW unless the label would be clipped by the tank
// floor (thresholds at the very bottom — dryRun=tiny% means
// dryRunLevel sits right on the floor). Then stack ABOVE the
// previous label instead, even if it slightly crowds its own line.
const belowY = lines[i].line + BELOW_OFFSET;
if (belowY + LABEL_H <= TANK_BOT) {
lines[i].y = belowY;
} else {
lines[i].y = prev.y + LABEL_H + 2; // stack above with 2 px gap
}
} else {
lines[i].y = lines[i].line - ABOVE_OFFSET;
}
}
const ty = Object.fromEntries(lines.map((l) => [l.id, +l.y.toFixed(2)]));
// Canvas elements use `constraint: { horizontal: scale, vertical: scale }`
// with margin-style placement (top + bottom + left + right, all %s of the
// panel). Bottom = % from panel bottom, top = % from panel top. Width and
// height are derived as 100 - top - bottom, etc.
// We emit *all* placement margins precomputed so the JSON template stays
// declarative.
const LABEL_H_PCT = hp(16); // 16 px label height as % of frame
const LINE_H_PCT = hp(1); // 1 px line height as % of frame
const bMargin = (top, h) => +(100 - top - h).toFixed(2);
const lineBottom = (lineY) => +(100 - yp(lineY) - LINE_H_PCT).toFixed(2);
const labelBottom = (lblY) => +(100 - yp(lblY) - LABEL_H_PCT).toFixed(2);
return {
heightBasin: +heightBasin.toFixed(2),
outflowLevel: +outflowLevel.toFixed(3),
inflowLevel: +inflowLevel.toFixed(3),
overflowLevel: +overflowLevel.toFixed(3),
dryRunLevel: +dryRunLevel.toFixed(3),
highSafetyLevel: +highSafetyLevel.toFixed(3),
// Threshold line top margins (% from panel top)
y_overflow: yp(y_overflow),
y_highSafety: yp(y_highSafety),
y_inflow: yp(y_inflow),
y_dryRun: yp(y_dryRun),
y_outflow: yp(y_outflow),
// Threshold line bottom margins (% from panel bottom)
yb_overflow: lineBottom(y_overflow),
yb_highSafety: lineBottom(y_highSafety),
yb_inflow: lineBottom(y_inflow),
yb_dryRun: lineBottom(y_dryRun),
yb_outflow: lineBottom(y_outflow),
// Zone bottom margins (zones end at the next line below)
zb_spill: +(100 - yp(y_overflow)).toFixed(2), // ends at overflow line
zb_highSafety: +(100 - yp(y_highSafety)).toFixed(2), // ends at highSafety line
zb_operating: +(100 - yp(y_outflow)).toFixed(2), // ends at outflow line
zb_dead: +(100 - yp(TANK_BOT)).toFixed(2), // ends at floor
// Label top margins (% from panel top) and bottom margins (% from panel bottom)
ty_overflow: yp(ty.overflow),
ty_highSafety: yp(ty.highSafety),
ty_inflow: yp(ty.inflow),
ty_dryRun: yp(ty.dryRun),
ty_outflow: yp(ty.outflow),
tyb_overflow: labelBottom(ty.overflow),
tyb_highSafety: labelBottom(ty.highSafety),
tyb_inflow: labelBottom(ty.inflow),
tyb_dryRun: labelBottom(ty.dryRun),
tyb_outflow: labelBottom(ty.outflow),
};
}
// Collect every `meta.emittedFields` declared by panels in a template.
// Used by #39's parent panel filter — a parent panel whose emittedFields
// are fully covered by its children's panels is removed.
@@ -136,17 +345,92 @@ class DashboardApi {
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 }) {
const softwareType =
nodeConfig?.functionality?.softwareType ||
nodeConfig?.functionality?.software_type ||
'measurement';
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);
// 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) {
this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`);
return null;
@@ -168,7 +452,7 @@ class DashboardApi {
updateTemplatingVar(dashboard, 'measurement', measurementName);
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 }) {
@@ -208,69 +492,111 @@ class DashboardApi {
return false;
}
// Collect ids that constitute "this dashboardAPI + this child + its grandchildren"
// for the diff predicate. Pulls grandchildren via the existing extractChildren walk.
// Collect every node id in "this dashboardAPI + this child's full subtree" for
// the diff predicate. Recurses the whole registered-child tree (not just
// grandchildren) so a change anywhere below a wired root triggers a regen.
// `visited` guards cycles / diamond topologies.
subtreeIdsFor(dashboardApiNodeId, childSource) {
const ids = new Set();
if (dashboardApiNodeId) ids.add(dashboardApiNodeId);
const childId = childSource?.config?.general?.id;
if (childId) ids.add(childId);
for (const { childSource: gc } of this.extractChildren(childSource)) {
const gcId = gc?.config?.general?.id;
if (gcId) ids.add(gcId);
}
this._collectSubtreeIds(childSource, ids, new Set());
return ids;
}
_collectSubtreeIds(nodeSource, ids, visited) {
const id = nodeSource?.config?.general?.id;
if (id) {
if (visited.has(id)) return;
visited.add(id);
ids.add(id);
}
for (const { childSource } of this.extractChildren(nodeSource)) {
this._collectSubtreeIds(childSource, ids, visited);
}
}
// Compose a dashboard for a wired root and EVERY descendant in its registered-
// child tree. Operators wire only subtree roots; dashboardAPI recurses the
// parent-child relationships to discover the rest. Returns a flat, pre-order
// array (root first) of buildDashboard results.
generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) {
if (!rootSource?.config) {
this.logger.warn('generateDashboardsForGraph skipped: root source missing config');
return [];
}
const rootPosition = rootSource?.positionVsParent || rootSource?.config?.functionality?.positionVsParent;
const rootDash = this.buildDashboard({ nodeConfig: rootSource.config, positionVsParent: rootPosition });
if (!rootDash) return [];
const results = [rootDash];
if (!includeChildren) return results;
const children = this.extractChildren(rootSource);
for (const { childSource, positionVsParent } of children) {
const childDash = this.buildDashboard({ nodeConfig: childSource.config, positionVsParent });
if (childDash) results.push(childDash);
const results = [];
this._composeNode(rootSource, includeChildren, results, new Set());
return results;
}
// 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) {
// 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 results.slice(1)) {
for (const dash of childDashes) {
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) => {
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 !== rootDash.dashboard.panels.length) {
if (this.logger?.debug && before !== parentDash.dashboard.panels.length) {
this.logger.debug({
event: 'parent-panels-deduped',
before,
after: rootDash.dashboard.panels.length,
rootTitle: rootDash.title,
after: parentDash.dashboard.panels.length,
rootTitle: parentDash.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 : [];
_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';
@@ -278,7 +604,7 @@ class DashboardApi {
const childUid = stableUid(`${childSoftwareType}:${childNodeId}`);
const childTitle = childConfig?.general?.name || String(childNodeId);
rootDash.dashboard.links.push({
parentDash.dashboard.links.push({
type: 'link',
title: childTitle,
url: `/d/${childUid}/${slugify(childTitle)}`,
@@ -290,7 +616,221 @@ class DashboardApi {
}
}
return results;
// Software types that count as a "pump" child of a machine group. Mirrors the
// template-alias map: a rotatingMachine reports softwareType 'rotatingmachine'
// in production, 'machine' in tests / shared template.
static _PUMP_SOFTWARE_TYPES = new Set(['rotatingmachine', 'machine']);
// Replicate the measurement-name convention from outputUtils.formatMsg /
// buildDashboard so the dashboard queries the exact series each pump writes:
// `general.name` when set, else `<softwareType>_<id>`.
_measurementNameForConfig(config) {
const softwareType = config?.functionality?.softwareType || 'measurement';
return config?.general?.name || `${softwareType}_${config?.general?.id || softwareType}`;
}
// Datasource block reused for injected panels. Pull it off an existing panel
// so the dashboard keeps a single influxdb datasource uid; fall back to the
// template's known uid if every panel was deduped away.
_datasourceFor(dashboard) {
const withDs = (dashboard.panels || []).find((p) => p?.datasource?.type === 'influxdb');
return withDs?.datasource || { type: 'influxdb', uid: 'cdzg44tv250jkd' };
}
// Build the per-pump + group-aggregate timeseries panels for a machineGroup
// dashboard. The operator asked for one graph each of pump % control, pump
// predicted flow, and pump predicted power, with the group total folded in,
// the resolved demand overlaid on the flow graph, and the flow-capacity
// envelope drawn as dashed min/max lines.
//
// Per-pump series live in each pump's OWN InfluxDB measurement (not the
// MGC's), so the queries are generated at compose time from the known child
// topology. Pump series are kept by `_measurement` (legend = pump name);
// group series are kept by `_field` and renamed via byName overrides.
_injectMachineGroupPumpPanels(parentDash, children) {
if (!parentDash?.dashboard) return;
const st = String(parentDash.softwareType || '').toLowerCase();
if (st !== 'machinegroupcontrol' && st !== 'machinegroup') return;
const pumps = (children || [])
.map(({ childSource }) => childSource?.config)
.filter((c) => c && DashboardApi._PUMP_SOFTWARE_TYPES.has(
String(c?.functionality?.softwareType || '').toLowerCase()))
.map((c) => ({ measurement: this._measurementNameForConfig(c), title: c?.general?.name || c?.general?.id }));
if (pumps.length === 0) return; // No pumps wired → leave the static totals.
const dashboard = parentDash.dashboard;
const datasource = this._datasourceFor(dashboard);
// The richer flow/power panels below supersede the static group-total
// panels — drop them so the same series isn't drawn twice.
dashboard.panels = (dashboard.panels || []).filter(
(p) => p.title !== 'Total Flow' && p.title !== 'Total Power');
const measFilter = pumps.map((p) => `r._measurement == "${p.measurement}"`).join(' or ');
const nextId = Math.max(0, ...dashboard.panels.map((p) => Number(p.id) || 0)) + 1;
dashboard.panels.push(
this._pumpControlPanel({ datasource, measFilter, id: nextId, y: 6 }),
this._pumpFlowPanel({ datasource, measFilter, id: nextId + 1, y: 14 }),
this._pumpPowerPanel({ datasource, measFilter, id: nextId + 2, y: 22 }),
);
}
// ── Injected-panel builders ──────────────────────────────────────────────
// All three use `${bucket}` / `${measurement}` template vars (resolved by
// Grafana from the dashboard's templating list) plus literal pump measurement
// names. v.timeRangeStart/Stop/windowPeriod are Grafana-supplied.
_baseTsPanel({ datasource, id, y, title, targets, overrides = [], defaults = {} }) {
return {
datasource,
fieldConfig: {
defaults: { custom: { drawStyle: 'line', lineWidth: 2, fillOpacity: 5, showPoints: 'never' }, ...defaults },
overrides,
},
gridPos: { h: 8, w: 24, x: 0, y },
id,
options: { legend: { displayMode: 'list', placement: 'bottom' }, tooltip: { mode: 'multi' } },
targets,
title,
type: 'timeseries',
// Empty emittedFields: these panels intentionally duplicate child series
// and must never be removed by the no-duplication dedup pass.
meta: { emittedFields: [], dynamic: 'mgc-pump-fanout' },
};
}
// Pump series kept by `_measurement` → one line per pump, legend = pump name.
// `field` is exact-matched by default; pass `regex:true` to match a 4-segment
// MeasurementContainer key whose childId varies per pump. rotatingMachine
// writes its own predictions under childId = node id (e.g.
// `flow.predicted.atequipment.<pumpId>`), NOT a fixed `default`, so the
// flow/power series must match the position prefix, not an exact key.
_perPumpTarget({ measFilter, field, refId, transform = '', regex = false }) {
const fieldFilter = regex ? `r._field =~ /${field}/` : `r._field == "${field}"`;
return {
refId,
query:
`from(bucket: "\${bucket}")\n` +
` |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n` +
` |> filter(fn:(r) => (${measFilter}) and ${fieldFilter})\n` +
` |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n` +
transform +
` |> keep(columns: ["_time", "_value", "_measurement"])`,
};
}
// Group series kept by `_field` → legend = field name, renamed via byName
// overrides. `fields` is OR-joined into one query.
_groupFieldsTarget({ fields, refId }) {
const filter = fields.map((f) => `r._field == "${f}"`).join(' or ');
return {
refId,
query:
`from(bucket: "\${bucket}")\n` +
` |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n` +
` |> filter(fn:(r) => r._measurement == "\${measurement}" and (${filter}))\n` +
` |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n` +
` |> keep(columns: ["_time", "_value", "_field"])`,
};
}
_byName(name, properties) {
return { matcher: { id: 'byName', options: name }, properties };
}
_pumpControlPanel({ datasource, measFilter, id, y }) {
// Two series per pump so an operator can see at a glance whether each pump
// actually moved to where the MGC told it:
// • realized position — the bare `ctrl` field (getCurrentPosition), solid.
// • commanded setpoint — `ctrl.predicted.atequipment.<pumpId>`, the % the
// pump computed from the MGC flow command (calcCtrl reverse curve),
// drawn dashed. childId varies per pump, so match the position prefix.
// Both are already 0..100 %, so they map straight onto a % axis — no scaling.
// Each series' `_measurement` is suffixed so the legend distinguishes the
// two lines per pump ("Pump A (realized)" vs "Pump A (setpoint)").
const label = (name) =>
` |> map(fn: (r) => ({ r with _measurement: r._measurement + " (${name})" }))\n`;
return this._baseTsPanel({
datasource, id, y,
title: 'Pump % Control',
defaults: { unit: 'percent', min: 0, max: 100 },
targets: [
this._perPumpTarget({ measFilter, field: 'ctrl', refId: 'A', transform: label('realized') }),
this._perPumpTarget({
measFilter, field: '^ctrl\\.predicted\\.atequipment\\.', refId: 'B',
regex: true, transform: label('setpoint'),
}),
],
overrides: [{
matcher: { id: 'byRegexp', options: '.*\\(setpoint\\)' },
properties: [{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [6, 6] } }],
}],
});
}
_pumpFlowPanel({ datasource, measFilter, id, y }) {
return this._baseTsPanel({
datasource, id, y,
title: 'Pump Predicted Flow vs Demand',
defaults: { unit: 'm3/h' },
targets: [
this._perPumpTarget({ measFilter, field: '^flow\\.predicted\\.atequipment\\.', refId: 'A', regex: true }),
this._groupFieldsTarget({
refId: 'B',
fields: ['atEquipment_predicted_flow', 'demandFlow', 'demandPct', 'flowCapacityMin', 'flowCapacityMax'],
}),
],
overrides: [
this._byName('atEquipment_predicted_flow', [
{ id: 'displayName', value: 'Total flow' },
{ id: 'custom.lineWidth', value: 3 },
]),
this._byName('demandFlow', [
{ id: 'displayName', value: 'Flow demand (setpoint)' },
{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [6, 6] } },
{ id: 'color', value: { mode: 'fixed', fixedColor: 'blue' } },
]),
this._byName('demandPct', [
{ id: 'displayName', value: 'Demand %' },
{ id: 'unit', value: 'percent' },
{ id: 'custom.axisPlacement', value: 'right' },
{ id: 'custom.axisLabel', value: '% control' },
{ id: 'color', value: { mode: 'fixed', fixedColor: 'purple' } },
]),
this._byName('flowCapacityMin', [
{ id: 'displayName', value: 'Capacity min' },
{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [10, 10] } },
{ id: 'custom.fillOpacity', value: 0 },
{ id: 'color', value: { mode: 'fixed', fixedColor: 'orange' } },
]),
this._byName('flowCapacityMax', [
{ id: 'displayName', value: 'Capacity max' },
{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [10, 10] } },
{ id: 'custom.fillOpacity', value: 0 },
{ id: 'color', value: { mode: 'fixed', fixedColor: 'red' } },
]),
],
});
}
_pumpPowerPanel({ datasource, measFilter, id, y }) {
return this._baseTsPanel({
datasource, id, y,
title: 'Pump Predicted Power',
defaults: { unit: 'kwatt' },
targets: [
this._perPumpTarget({ measFilter, field: '^power\\.predicted\\.atequipment\\.', refId: 'A', regex: true }),
this._groupFieldsTarget({ refId: 'B', fields: ['atEquipment_predicted_power'] }),
],
overrides: [
this._byName('atEquipment_predicted_power', [
{ id: 'displayName', value: 'Total power' },
{ id: 'custom.lineWidth', value: 3 },
]),
],
});
}
}

68
test/_output-manifest.md Normal file
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);
});
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 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);
});
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({});
// Pre-populate cache as if two children had registered.
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: [] };
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).
assert.ok(sends.length >= 2, `expected ≥2 emitted msgs, got ${sends.length}`);
// Every emitted msg carries trigger: 'manual' in meta.
for (const m of sends) assert.equal(m.meta?.trigger, 'manual');
});
test('child.register stamps trigger: child.register in emitted msg meta', () => {
test('child.register stamps trigger: child.register in emitted msg meta', async () => {
const api = new DashboardApi({});
api.lastFlowsStartedDiff = null; // cold-start → always regen
const sends = [];
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);
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 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');
});