15 Commits

Author SHA1 Message Date
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
2b745dfb51 example(dashboardapi): basic.flow.json demos end-to-end Grafana round-trip (#42)
Replaces the placeholder inject→dashboardapi→debug example with the full
chain: inject (simulating a measurement child registration) → dashboardapi
(composes dashboard JSON) → http request (POSTs to Grafana) → debug (shows
the response). Default targets http://grafana:3000 inside the Docker compose
network. Configure bearer token via the encrypted credentials field.

Refs #42
2026-05-26 18:06:54 +02:00
3c8427ed7a feat(dashboardapi): manual regen via msg.topic == regenerate-dashboard (#41)
Adds an explicit topic for operators (and the dashboardAPI v2 manual escape
hatch from PRD F-12). On `regenerate-dashboard`, dashboardAPI iterates every
child source cached by prior `child.register` messages and re-emits Grafana
upsert messages — bypassing the diff-skip predicate from #36.

- src/specificClass.js: light state cache (recordChild / cachedChildSources).
- src/commands/handlers.js: refactor shared emit path; emitDashboardsFor()
  used by both child.register and regenerateDashboard; meta.trigger
  distinguishes the two for downstream filtering.
- src/commands/index.js: register 'regenerate-dashboard' (alias 'regen').
- CONTRACT.md: document the new topic.
- test/basic/slice41-manual-regen.basic.test.js: 5 cases covering cache
  semantics, no-op for empty cache, bypass-predicate, trigger stamp on both
  paths, registry exposure.

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

View File

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

View File

@@ -22,6 +22,58 @@ function resolveChildNode(childId, ctx) {
return runtimeNode || flowNode || null;
}
// Shared emit path used by both child.register (auto, deploy-driven) and
// regenerate-dashboard (manual). `trigger` distinguishes the two for logs.
async function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
const dashboards = source.generateDashboardsForGraph(childSource, {
includeChildren: Boolean(msg.includeChildren ?? true),
});
const url = source.grafanaUpsertUrl();
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
const token = source.config?.grafanaConnector?.bearerToken;
if (token) headers.Authorization = `Bearer ${token}`;
// Resolve the folder by name (creating it if missing) so a rebuilt Grafana's
// fresh folder uid never strands the upserts on a stale pinned uid. Falls
// back to the configured folderUid on any failure.
const folderUid = typeof source.resolveFolderUid === 'function'
? await source.resolveFolderUid()
: (source.config?.grafanaConnector?.folderUid || undefined);
for (const dash of dashboards) {
ctx.send({
...msg,
topic: 'create',
url,
method: 'POST',
headers,
payload: source.buildUpsertRequest({
dashboard: dash.dashboard,
folderUid: folderUid || undefined,
overwrite: true,
}),
meta: {
nodeId: dash.nodeId,
softwareType: dash.softwareType,
uid: dash.uid,
title: dash.title,
trigger,
},
});
}
if (source.logger?.info) {
source.logger.info({
event: 'regen-emitted',
trigger,
dashboardApiId: ctx.node?.id,
childId: childSource?.config?.general?.id,
dashboardCount: dashboards.length,
});
}
}
// On child.register: build the dashboard graph (root + direct children) and
// emit one Grafana upsert HTTP request per dashboard on Port 0.
//
@@ -29,12 +81,15 @@ function resolveChildNode(childId, ctx) {
// 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');
}
// Cache the child source for later manual regen (#41).
source.recordChild?.(childSource);
const subtreeIds = source.subtreeIdsFor(ctx.node?.id, childSource);
const changed = source.subtreeChanged(source.lastFlowsStartedDiff, subtreeIds);
if (!changed) {
@@ -51,35 +106,25 @@ function registerChild(source, msg, ctx) {
return;
}
const dashboards = source.generateDashboardsForGraph(childSource, {
includeChildren: Boolean(msg.includeChildren ?? true),
});
await emitDashboardsFor(source, childSource, ctx, msg, 'child.register');
}
const url = source.grafanaUpsertUrl();
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
const token = source.config?.grafanaConnector?.bearerToken;
if (token) headers.Authorization = `Bearer ${token}`;
for (const dash of dashboards) {
ctx.send({
...msg,
topic: 'create',
url,
method: 'POST',
headers,
payload: source.buildUpsertRequest({
dashboard: dash.dashboard,
folderUid: source.config?.grafanaConnector?.folderUid || undefined,
overwrite: true,
}),
meta: {
nodeId: dash.nodeId,
softwareType: dash.softwareType,
uid: dash.uid,
title: dash.title,
},
// On regenerate-dashboard: re-emit dashboards for every cached child source,
// bypassing the diff predicate. Useful as an operator escape hatch when
// auto-regen missed an edge case or when the operator just wants to refresh.
async function regenerateDashboard(source, msg, ctx) {
const cached = source.cachedChildSources?.() || [];
if (source.logger?.info) {
source.logger.info({
event: 'manual-regen-requested',
trigger: 'manual',
dashboardApiId: ctx.node?.id,
cachedChildCount: cached.length,
});
}
for (const childSource of cached) {
await emitDashboardsFor(source, childSource, ctx, msg, 'manual');
}
}
module.exports = { registerChild };
module.exports = { registerChild, regenerateDashboard };

View File

@@ -13,4 +13,10 @@ module.exports = [
payloadSchema: { type: 'any' },
handler: handlers.registerChild,
},
{
topic: 'regenerate-dashboard',
aliases: ['regen'],
payloadSchema: { type: 'any' },
handler: handlers.regenerateDashboard,
},
];

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 || '',
@@ -75,6 +117,20 @@ class DashboardApi {
this.config.general.logging.logLevel,
this.config.general.name
);
// Light state cache for manual regen (#41). Stores the latest child
// source object per child id so `regenerate-dashboard` can re-emit
// dashboards without waiting for children to re-register.
this._lastChildSources = new Map();
}
recordChild(childSource) {
const id = childSource?.config?.general?.id;
if (id) this._lastChildSources.set(id, childSource);
}
cachedChildSources() {
return Array.from(this._lastChildSources.values());
}
_templatesDir() {
@@ -84,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) {
@@ -98,13 +154,102 @@ 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);
// Canvas tank: rim at y=20px, floor at y=700px (680px tall). Must match
// hard-coded tank rectangle placement in config/pumpingStation.json
// (basin row is h:20 grid rows; canvas root frame is 400x760 px — taller
// than wide to match the card's aspect ratio so the tank fills the card
// vertically with no letterboxing).
const TANK_TOP = 20, TANK_BOT = 700, TANK_H = TANK_BOT - TANK_TOP;
const yFor = (v) => +(TANK_BOT - (v / heightBasin) * TANK_H).toFixed(2);
const tyFor = (yLine) => +(yLine - 8).toFixed(2); // centre 16px text on the line
const y_overflow = yFor(overflowLevel);
const y_highSafety = yFor(highSafetyLevel);
const y_inflow = yFor(inflowLevel);
const y_dryRun = yFor(dryRunLevel);
const y_outflow = yFor(outflowLevel);
// Label y-positions: 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 ABOVE_OFFSET = 22; // label_top = line_y - 22 (label bottom is 6 px clear above the line)
const BELOW_OFFSET = 6; // label_top = line_y + 6 (label is 6 px clear below the line)
const MIN_DIST_FOR_ABOVE = 24; // if distance to previous (upper) line < this, go 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;
lines[i].y = tooClose ? (lines[i].line + BELOW_OFFSET) : (lines[i].line - ABOVE_OFFSET);
}
const ty = Object.fromEntries(lines.map((l) => [l.id, +l.y.toFixed(2)]));
return {
heightBasin: +heightBasin.toFixed(2),
outflowLevel: +outflowLevel.toFixed(3),
inflowLevel: +inflowLevel.toFixed(3),
overflowLevel: +overflowLevel.toFixed(3),
dryRunLevel: +dryRunLevel.toFixed(3),
highSafetyLevel: +highSafetyLevel.toFixed(3),
y_overflow, y_highSafety, y_inflow, y_dryRun, y_outflow,
h_spill: +(y_overflow - TANK_TOP).toFixed(2),
h_highSafety: +(y_highSafety - y_overflow).toFixed(2),
h_operating: +(y_outflow - y_highSafety).toFixed(2),
h_dead: +(TANK_BOT - y_outflow).toFixed(2),
ty_overflow: ty.overflow,
ty_highSafety: ty.highSafety,
ty_inflow: ty.inflow,
ty_dryRun: ty.dryRun,
ty_outflow: ty.outflow,
};
}
// Collect every `meta.emittedFields` declared by panels in a template.
// Used by #39's parent panel filter — a parent panel whose emittedFields
// are fully covered by its children's panels is removed.
@@ -122,17 +267,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;
@@ -154,7 +374,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 }) {
@@ -194,69 +414,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';
@@ -264,7 +526,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)}`,
@@ -276,7 +538,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

@@ -0,0 +1,75 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
const handlers = require('../../src/commands/handlers.js');
function makeCtx(sends, nodeId = 'dApi-1') {
return {
node: { id: nodeId },
RED: { nodes: { getNode: () => null } },
send: (m) => sends.push(m),
logger: null,
};
}
function makeChildPayload(id, softwareType = 'measurement') {
return {
config: {
general: { id, name: id },
functionality: { softwareType, positionVsParent: 'downstream' },
},
};
}
test('recordChild caches child source by id; subsequent ones replace by id', () => {
const api = new DashboardApi({});
api.recordChild(makeChildPayload('a'));
api.recordChild(makeChildPayload('b'));
api.recordChild(makeChildPayload('a')); // replace
assert.equal(api.cachedChildSources().length, 2);
});
test('regenerate-dashboard with no cached children is a no-op (no msgs emitted)', async () => {
const api = new DashboardApi({});
const sends = [];
await handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, makeCtx(sends));
assert.equal(sends.length, 0);
});
test('regenerate-dashboard re-emits for each cached child, bypassing diff', async () => {
const api = new DashboardApi({});
// Pre-populate cache as if two children had registered.
api.recordChild(makeChildPayload('m-1'));
api.recordChild(makeChildPayload('m-2'));
// Set a diff that says nothing changed — registerChild would skip, but
// regenerateDashboard should ignore the predicate.
api.lastFlowsStartedDiff = { added: [], changed: [], removed: [], rewired: [] };
const sends = [];
await handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, makeCtx(sends));
// Each child yields at least one dashboard message (the root for the child's view).
assert.ok(sends.length >= 2, `expected ≥2 emitted msgs, got ${sends.length}`);
// Every emitted msg carries trigger: 'manual' in meta.
for (const m of sends) assert.equal(m.meta?.trigger, 'manual');
});
test('child.register stamps trigger: child.register in emitted msg meta', async () => {
const api = new DashboardApi({});
api.lastFlowsStartedDiff = null; // cold-start → always regen
const sends = [];
await handlers.registerChild(api, { topic: 'child.register', payload: makeChildPayload('m-3') }, makeCtx(sends));
assert.ok(sends.length >= 1);
for (const m of sends) assert.equal(m.meta?.trigger, 'child.register');
});
test('command registry exposes regenerate-dashboard with regen alias', () => {
const registry = require('../../src/commands/index.js');
const entry = registry.find((e) => e.topic === 'regenerate-dashboard');
assert.ok(entry, 'topic registered');
assert.deepEqual(entry.aliases, ['regen']);
assert.equal(typeof entry.handler, 'function');
});

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