37 Commits

Author SHA1 Message Date
znetsixe
de957cb971 fix(dashboardAPI): resolve InfluxDB datasource uid at push time
Templates baked in a hardcoded influxdb datasource uid that only matched the
Grafana the templates were authored against. Any other Grafana (fresh laptop,
VPS, rebuilt instance) rendered every panel as "Datasource <uid> not found".

resolveDatasourceUid() queries GET /api/datasources, picks the first influxdb
one, and caches the result. rewriteDatasourceUid() then walks panels, nested
row panels, panel.targets[], and templating.list[] and rewrites every influxdb
uid before the dashboard is pushed. Annotation datasources (type: "grafana")
and template-variable refs ($datasource) are left alone. Failure is silent and
panels keep the template uid, so behavior is never worse than before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:33:22 +02:00
znetsixe
533f74fe7e chore(dashboardAPI): bigger rim/floor labels with guaranteed clearance
Rim ('rim (X m)') and floor ('floor (0.00 m)') captions now use the same
size 14 font as the threshold labels (was size 10 — visibly smaller and
hard to read on a high-resolution dashboard). To make room for the larger
text while ensuring rim/floor can NEVER overlap the topmost (overflow) or
bottommost (outflow) threshold lines at any basin geometry, the tank's
vertical margins are bumped from 40 px to 48 px each:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:32:45 +02:00
znetsixe
8a26e17780 chore(dashboardAPI): Tank Layout fills card vertically too
Canvas frame height 600 → 760 px and tank rectangle height 520 → 680 px
so the visual fills the card aspect (taller than wide). Floor footer
moves to y=702 (was 542) to stay just below the new tank floor.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Backfill manifests for other nodes tracked in IMPROVEMENTS_BACKLOG.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:42:14 +02:00
znetsixe
a6f09d821d docs: Folder & File Layout section + flag dashboardapi.{js,html} naming drift
Entry/HTML files should be dashboardAPI.{js,html} (case-sensitive match with
the folder name). Rename when the file is next touched. Full rule:
.claude/rules/node-architecture.md in the EVOLV superproject.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:30:46 +02:00
znetsixe
f0a7904985 P11.7 wiki: rewrite Home.md to full 14-section visual-first template
Adapts the canonical WIKI_TEMPLATE.md for dashboardAPI as a utility node
(no BaseDomain, no S88 level, no state chart). Key changes vs P9.3 draft:
- Banner hash bumped to 7b3da23
- Section 1: tightened to exactly describe topology→dashboard flow
- Section 2: adds FlowFuse/browser as downstream consumer of Grafana dashboards
- Section 3: expands capabilities (stable UID, bucket-per-position, alias alias)
- Section 4: adds dashboardapi.js entry node + real config/ template list
- Section 5: AUTOGEN markers regenerated via npm run wiki:all
- Section 6: rewrites diagram with resolveChildSource detail
- Section 7: full sequence including stableUid + links[] step
- Section 8: AUTOGEN marker regenerated; adds meta-field table
- Section 9: adds enableLog/logLevel fields; adds bucket-fallback table
- Section 10: explicit SKIPPED marker (stateless node)
- Section 11: adds inline wiring example
- Section 12: expands to 7 recipes (adds UID-change, machineGroupControl alias)
- Section 13: adds "not a BaseDomain node" + OPEN_QUESTIONS reference
- Section 14: adds OPEN_QUESTIONS.md link for BaseDomain decision; keeps 5 issues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:06:42 +02:00
znetsixe
7b3da23fba P11.6 wiki regen + Phase 10 private-test rewrites where applicable
For all 11 nodes with auto-gen markers: wiki/Home.md sections 5 (topic
contract) and 9 (data model) regenerated via npm run wiki:all. New
Unit column shows '<measure> (default <unit>)' for declared topics,
'—' otherwise. Effect column now uses descriptor.description (P11.2
field) overriding the generic per-prefix fallback.

For rotatingMachine + reactor: Phase 10 test rewrites — 3 + 8 files
moved off private nodeClass internals (_attachInputHandler, _commands,
_pendingExtras, _registerChild, _tick, etc.) to the public
BaseNodeAdapter surface (node.handlers.input, node.source.*).
+6 / +7 net new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:44:02 +02:00
znetsixe
67a374ff4f P9.3: wiki/Home.md following 14-section visual-first template + wiki:* scripts
Auto-generated topic-contract + data-model sections via shared wikiGen
script. Hand-written Mermaid diagrams for position-in-platform, code
map, child registration, lifecycle, configuration, state chart (where
applicable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:17:46 +02:00
znetsixe
92d7eba0fd P10.2: convert remaining dashboardAPI tests from Mocha to node:test
P6.7 converted test/basic/. Convert test/edge/ and test/integration/ the
same way: describe/it/expect → test/assert. No behavioural change.

5 / 5 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:44:15 +02:00
47 changed files with 5700 additions and 429 deletions

View File

@@ -21,3 +21,21 @@ Key points for this node:
- Stack same-level siblings vertically. - Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right). - Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- Wrap in a Node-RED group box coloured `none` (Utility (no S88 level)). - Wrap in a Node-RED group box coloured `none` (Utility (no S88 level)).
## Folder & File Layout
Every per-node file MUST use the folder name (`dashboardAPI`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
| Path | Required name |
|---|---|
| Entry file | `dashboardAPI.js` |
| Editor HTML | `dashboardAPI.html` |
| Node adapter | `src/nodeClass.js` |
| Domain logic | `src/specificClass.js` |
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
| Tests | `test/{basic,integration,edge}/*.test.js` |
| Example flows | `examples/*.flow.json` |
> **Note on the Node-RED type id.** The files are now `dashboardAPI.{js,html}` (folder-name convention satisfied 2026-05-19), but the registered type id stays lowercase: `RED.nodes.registerType('dashboardapi', …)`. Every deployed flow references the type id, not the file name, so this preserves backward compatibility. Admin endpoints (`/dashboardapi/menu.js`, `/dashboardapi/configData.js`) follow the type id and are also unchanged.
When adding new files, read the rule above first to avoid drift.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,10 @@
"list": [ "list": [
{ {
"builtIn": 1, "builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" }, "datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true, "enable": true,
"hide": true, "hide": true,
"iconColor": "rgba(0, 211, 255, 1)", "iconColor": "rgba(0, 211, 255, 1)",
@@ -17,150 +20,557 @@
"id": null, "id": null,
"links": [], "links": [],
"panels": [ "panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Status", "type": "row" }, {
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"id": 1,
"title": "Status",
"type": "row"
},
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } }, "overrides": [] }, "fieldConfig": {
"gridPos": { "h": 4, "w": 5, "x": 0, "y": 1 }, "defaults": {
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "blue", "value": null }]
}
},
"overrides": []
},
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 },
"id": 2, "id": 2,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" }, "options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "/.*/" },
"colorMode": "value",
"graphMode": "none"
},
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"direction\")\n |> last()", "refId": "A" } {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"direction\")\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
"refId": "A"
}
], ],
"title": "Direction", "title": "Direction",
"type": "stat" "type": "stat",
"meta": { "emittedFields": ["direction"] }
}, },
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 300 }, { "color": "red", "value": 600 }] } }, "overrides": [] }, "fieldConfig": {
"gridPos": { "h": 4, "w": 5, "x": 5, "y": 1 }, "defaults": {
"unit": "s",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 300 },
{ "color": "red", "value": 600 }
]
}
},
"overrides": []
},
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 },
"id": 3, "id": 3,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" }, "options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "area"
},
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"timeleft\")\n |> last()", "refId": "A" } {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"timeleft\")\n |> group(columns:[\"_field\"])\n |> last()",
"refId": "A"
}
], ],
"title": "Time Left", "title": "Time Left",
"type": "stat" "type": "stat",
"meta": { "emittedFields": ["timeLeft"] }
}, },
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "purple", "value": null }] } }, "overrides": [] }, "fieldConfig": {
"gridPos": { "h": 4, "w": 4, "x": 10, "y": 1 }, "defaults": {
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "purple", "value": null }]
}
},
"overrides": []
},
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 },
"id": 4, "id": 4,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" }, "options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "/.*/" },
"colorMode": "value",
"graphMode": "none"
},
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"flowSource\")\n |> last()", "refId": "A" } {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field==\"flowSource\")\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
"refId": "A"
}
], ],
"title": "Flow Source", "title": "Flow Source",
"type": "stat" "type": "stat",
"meta": { "emittedFields": ["flowSource"] }
}, },
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "min": 0, "max": 100, "unit": "percent", "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "orange", "value": 20 }, { "color": "green", "value": 40 }, { "color": "orange", "value": 80 }, { "color": "red", "value": 95 }] } }, "overrides": [] }, "fieldConfig": {
"gridPos": { "h": 4, "w": 5, "x": 14, "y": 1 }, "defaults": {
"id": 5, "unit": "lengthm",
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "showThresholdLabels": false, "showThresholdMarkers": true }, "thresholds": {
"targets": [ "mode": "absolute",
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^volumePercent\\.predicted\\.atequipment/)\n |> last()", "refId": "A" } "steps": [{ "color": "green", "value": null }]
], }
"title": "Fill %",
"type": "gauge"
}, },
{ "overrides": []
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, },
"fieldConfig": { "defaults": { "unit": "m", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } }, "overrides": [] }, "gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 },
"gridPos": { "h": 4, "w": 5, "x": 19, "y": 1 },
"id": 6, "id": 6,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" }, "options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "area"
},
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.predicted\\.atequipment/)\n |> last()", "refId": "A" } {
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.predicted\\.atequipment/)\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
"refId": "A"
}
], ],
"title": "Level", "title": "Level",
"type": "stat" "type": "stat",
"meta": { "emittedFields": ["level"] }
},
{
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 },
"id": 13,
"title": "Basin",
"type": "row"
}, },
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, "id": 7, "title": "Basin", "type": "row" },
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] }, "fieldConfig": {
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, "defaults": {
"unit": "lengthm",
"min": 0,
"max": {{heightBasin}},
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "#3a3a3a", "value": null },
{ "color": "semi-dark-grey", "value": {{outflowLevel}} },
{ "color": "blue", "value": {{dryRunLevel}} },
{ "color": "green", "value": {{inflowLevel}} },
{ "color": "orange", "value": {{highSafetyLevel}} },
{ "color": "red", "value": {{overflowLevel}} }
]
}
},
"overrides": []
},
"gridPos": { "h": 20, "w": 4, "x": 0, "y": 6 },
"id": 16,
"options": {
"displayMode": "basic",
"orientation": "vertical",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "" },
"showThresholdLabels": true,
"showThresholdMarkers": true,
"showUnfilled": true,
"minVizWidth": 8,
"minVizHeight": 16,
"valueMode": "color",
"namePlacement": "auto"
},
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.predicted\\.atequipment/)\n |> group(columns:[\"_field\"])\n |> last()\n |> keep(columns:[\"_value\"])",
"refId": "A"
}
],
"title": "Water Level",
"type": "bargauge",
"meta": { "emittedFields": ["basinLevel"] }
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": {
"defaults": {
"unit": "none",
"decimals": 2
},
"overrides": [
{
"matcher": { "id": "byRegexp", "options": "^(outflowLevel|inflowLevel|overflowLevel|heightBasin|dryRunLevel|highVolumeSafetyLevel|level)$" },
"properties": [{ "id": "unit", "value": "lengthm" }, { "id": "decimals", "value": 2 }]
},
{
"matcher": { "id": "byRegexp", "options": "^(volume|maxVol|minVol|maxVolAtOverflow|minVolAtOutflow|minVolAtInflow)$" },
"properties": [{ "id": "unit", "value": "m3" }, { "id": "decimals", "value": 2 }]
},
{
"matcher": { "id": "byRegexp", "options": "^volumePercent$" },
"properties": [{ "id": "unit", "value": "percent" }, { "id": "decimals", "value": 1 }]
}
]
},
"gridPos": { "h": 20, "w": 6, "x": 4, "y": 6 },
"id": 17,
"options": {
"inlineEditing": false,
"showAdvancedTypes": true,
"panZoom": false,
"infinitePan": false,
"root": {
"name": "Basin",
"type": "frame",
"placement": { "left": 0, "top": 0, "right": 0, "bottom": 0 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "dark-green" } },
"elements": [
{
"name": "Zone Spill",
"type": "rectangle",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 6.32, "left": 2.5, "right": 2.5, "bottom": {{zb_spill}} },
"background": { "color": { "fixed": "rgba(229, 67, 67, 0.18)" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
},
{
"name": "Zone HighSafety",
"type": "rectangle",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_overflow}}, "left": 2.5, "right": 2.5, "bottom": {{zb_highSafety}} },
"background": { "color": { "fixed": "rgba(242, 165, 67, 0.16)" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
},
{
"name": "Zone Operating",
"type": "rectangle",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_highSafety}}, "left": 2.5, "right": 2.5, "bottom": {{zb_operating}} },
"background": { "color": { "fixed": "rgba(95, 179, 122, 0.14)" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
},
{
"name": "Zone Dead",
"type": "rectangle",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_outflow}}, "left": 2.5, "right": 2.5, "bottom": {{zb_dead}} },
"background": { "color": { "fixed": "rgba(128, 128, 128, 0.20)" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
},
{
"name": "Tank Outline",
"type": "rectangle",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 6.32, "left": 2.5, "right": 2.5, "bottom": 6.32 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "#8a8a8a" }, "width": 2 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
},
{
"name": "Line Overflow",
"type": "rectangle",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_overflow}}, "left": 2.5, "right": 2.5, "bottom": {{yb_overflow}} },
"background": { "color": { "fixed": "#e54343" } },
"border": { "color": { "fixed": "#e54343" }, "width": 0 }
},
{
"name": "Line HighSafety",
"type": "rectangle",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_highSafety}}, "left": 2.5, "right": 2.5, "bottom": {{yb_highSafety}} },
"background": { "color": { "fixed": "#f2a543" } },
"border": { "color": { "fixed": "#f2a543" }, "width": 0 }
},
{
"name": "Line Inflow",
"type": "rectangle",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_inflow}}, "left": 2.5, "right": 2.5, "bottom": {{yb_inflow}} },
"background": { "color": { "fixed": "#5fb37a" } },
"border": { "color": { "fixed": "#5fb37a" }, "width": 0 }
},
{
"name": "Line DryRun",
"type": "rectangle",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_dryRun}}, "left": 2.5, "right": 2.5, "bottom": {{yb_dryRun}} },
"background": { "color": { "fixed": "#5b9bd5" } },
"border": { "color": { "fixed": "#5b9bd5" }, "width": 0 }
},
{
"name": "Line Outflow",
"type": "rectangle",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_outflow}}, "left": 2.5, "right": 2.5, "bottom": {{yb_outflow}} },
"background": { "color": { "fixed": "#bfbfbf" } },
"border": { "color": { "fixed": "#bfbfbf" }, "width": 0 }
},
{
"name": "Label Overflow Name",
"type": "text",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_overflow}}, "left": 15, "right": 53, "bottom": {{tyb_overflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Label HighSafety Name",
"type": "text",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_highSafety}}, "left": 15, "right": 53, "bottom": {{tyb_highSafety}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "highSafety" }, "color": { "fixed": "#cf7e20" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Label Inflow Name",
"type": "text",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_inflow}}, "left": 15, "right": 53, "bottom": {{tyb_inflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Label DryRun Name",
"type": "text",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_dryRun}}, "left": 15, "right": 53, "bottom": {{tyb_dryRun}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Label Outflow Name",
"type": "text",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_outflow}}, "left": 15, "right": 53, "bottom": {{tyb_outflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Value Overflow",
"type": "metric-value",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_overflow}}, "left": 53, "right": 12, "bottom": {{tyb_overflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Value HighSafety",
"type": "metric-value",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_highSafety}}, "left": 53, "right": 12, "bottom": {{tyb_highSafety}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "highVolumeSafetyLevel" }, "color": { "fixed": "#cf7e20" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Value Inflow",
"type": "metric-value",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_inflow}}, "left": 53, "right": 12, "bottom": {{tyb_inflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Value DryRun",
"type": "metric-value",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_dryRun}}, "left": 53, "right": 12, "bottom": {{tyb_dryRun}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Value Outflow",
"type": "metric-value",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_outflow}}, "left": 53, "right": 12, "bottom": {{tyb_outflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Header Rim",
"type": "text",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 1, "left": 2.5, "right": 2.5, "bottom": 95 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "rim ({{heightBasin}} m)" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "center", "valign": "middle" }
},
{
"name": "Footer Floor",
"type": "text",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 95, "left": 2.5, "right": 2.5, "bottom": 1 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "floor (0.00 m)" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "center", "valign": "middle" }
}
]
}
},
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"outflowLevel\" or r._field==\"inflowLevel\" or r._field==\"overflowLevel\" or r._field==\"heightBasin\" or r._field==\"dryRunLevel\" or r._field==\"highVolumeSafetyLevel\" or r._field =~ /^level\\.predicted\\.atequipment/ or r._field =~ /^volume\\.predicted\\.atequipment/ or r._field =~ /^volumePercent\\.predicted\\.atequipment/))\n |> last()\n |> map(fn: (r) => ({ r with _field: if r._field =~ /^volumePercent\\.predicted/ then \"volumePercent\" else if r._field =~ /^volume\\.predicted/ then \"volume\" else if r._field =~ /^level\\.predicted/ then \"level\" else r._field, _time: 2020-01-01T00:00:00Z }))\n |> group()\n |> keep(columns:[\"_field\",\"_value\",\"_time\"])\n |> pivot(rowKey:[\"_time\"], columnKey:[\"_field\"], valueColumn:\"_value\")",
"refId": "A"
}
],
"title": "Tank Layout",
"type": "canvas",
"meta": { "emittedFields": ["basinLayout"] }
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": {
"defaults": {
"unit": "lengthm",
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 }
},
"overrides": []
},
"gridPos": { "h": 10, "w": 14, "x": 10, "y": 6 },
"id": 8, "id": 8,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, "options": {
"legend": { "displayMode": "list", "placement": "bottom" },
"tooltip": { "mode": "multi" }
},
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.(predicted|measured)\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^level\\.(predicted|measured)\\.atequipment/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
], ],
"title": "Level", "title": "Level (over time)",
"type": "timeseries" "type": "timeseries",
"meta": { "emittedFields": ["level"] }
}, },
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m\u00b3", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] }, "fieldConfig": {
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, "defaults": {
"unit": "m3",
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 }
},
"overrides": []
},
"gridPos": { "h": 10, "w": 14, "x": 10, "y": 16 },
"id": 9, "id": 9,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, "options": {
"targets": [ "legend": { "displayMode": "list", "placement": "bottom" },
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^volume\\.predicted\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } "tooltip": { "mode": "multi" }
], },
"title": "Volume", "targets": [
"type": "timeseries" {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^volume\\.predicted\\.atequipment/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Volume (over time)",
"type": "timeseries",
"meta": { "emittedFields": ["volume"] }
},
{
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 26 },
"id": 10,
"title": "Flow",
"type": "row"
}, },
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, "id": 10, "title": "Flow", "type": "row" },
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m\u00b3/h", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] }, "fieldConfig": {
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, "defaults": {
"unit": "m3/h",
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 }
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 27 },
"id": 11, "id": 11,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, "options": {
"legend": { "displayMode": "list", "placement": "bottom" },
"tooltip": { "mode": "multi" }
},
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^netFlowRate\\.predicted\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^netFlowRate\\.(predicted|measured)\\.atequipment/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
], ],
"title": "Net Flow Rate", "title": "Net Flow Rate",
"type": "timeseries" "type": "timeseries",
"meta": { "emittedFields": ["flow.net", "flow"] }
}, },
{ {
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" }, "datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m\u00b3/h", "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [] }, "fieldConfig": {
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, "defaults": {
"unit": "m3/h",
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 }
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 27 },
"id": 12, "id": 12,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, "options": {
"legend": { "displayMode": "list", "placement": "bottom" },
"tooltip": { "mode": "multi" }
},
"targets": [ "targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^flow\\.(predicted|measured)\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } {
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^flow\\.(predicted|measured)\\.(upstream|in|out|overflow)/)\n |> group(columns:[\"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
], ],
"title": "Inflow + Outflow", "title": "Inflow + Outflow",
"type": "timeseries" "type": "timeseries",
}, "meta": { "emittedFields": ["flow.in", "flow.out"] }
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, "id": 13, "title": "Configuration", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m", "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 12, "x": 0, "y": 24 },
"id": 14,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"heightInlet\" or r._field==\"heightOverflow\" or r._field==\"volEmptyBasin\"))\n |> last()", "refId": "A" }
],
"title": "Heights",
"type": "stat"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "m\u00b3", "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } }, "overrides": [] },
"gridPos": { "h": 4, "w": 12, "x": 12, "y": 24 },
"id": 15,
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"targets": [
{ "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"maxVol\" or r._field==\"minVol\" or r._field==\"maxVolOverflow\" or r._field==\"minVolOut\" or r._field==\"minVolIn\"))\n |> last()", "refId": "A" }
],
"title": "Volume Limits",
"type": "stat"
} }
], ],
"schemaVersion": 39, "schemaVersion": 39,
"tags": ["EVOLV", "pumpingStation", "template"], "tags": ["EVOLV", "pumpingStation", "template"],
"templating": { "templating": {
"list": [ "list": [
{ "name": "dbase", "type": "custom", "label": "dbase", "query": "cdzg44tv250jkd", "current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false }, "options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }], "hide": 2 }, {
{ "name": "measurement", "type": "custom", "query": "template", "current": { "text": "template", "value": "template", "selected": false }, "options": [{ "text": "template", "value": "template", "selected": true }] }, "name": "dbase",
{ "name": "bucket", "type": "custom", "query": "lvl2", "current": { "text": "lvl2", "value": "lvl2", "selected": false }, "options": [{ "text": "lvl2", "value": "lvl2", "selected": true }] } "type": "custom",
"label": "dbase",
"query": "cdzg44tv250jkd",
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false },
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }],
"hide": 2
},
{
"name": "measurement",
"type": "custom",
"query": "template",
"current": { "text": "template", "value": "template", "selected": false },
"options": [{ "text": "template", "value": "template", "selected": true }]
},
{
"name": "bucket",
"type": "custom",
"query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
}
] ]
}, },
"time": { "from": "now-6h", "to": "now" }, "time": { "from": "now-6h", "to": "now" },

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,18 +4,22 @@
<script> <script>
RED.nodes.registerType('dashboardapi', { RED.nodes.registerType('dashboardapi', {
category: 'EVOLV', category: 'EVOLV',
color: '#4f8582', color: '#7A8BA3',
defaults: { defaults: {
name: { value: '' }, name: { value: '' },
enableLog: { value: false }, enableLog: { value: true },
logLevel: { value: 'info' }, logLevel: { value: 'info' },
protocol: { value: 'http' }, protocol: { value: 'http' },
host: { value: 'localhost' }, host: { value: 'localhost' },
port: { value: 3000 }, port: { value: 3000 },
bearerToken: { value: '' }, folderTitle: { value: '' },
folderUid: { value: '' },
defaultBucket: { value: '' }, defaultBucket: { value: '' },
}, },
credentials: {
bearerToken: { type: 'password' },
},
inputs: 1, inputs: 1,
outputs: 1, outputs: 1,
inputLabels: ['Input'], inputLabels: ['Input'],
@@ -44,11 +48,12 @@
window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node); window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node);
} }
['name', 'protocol', 'host', 'port', 'bearerToken', 'defaultBucket'].forEach((field) => { ['name', 'protocol', 'host', 'port', 'folderTitle', 'folderUid', 'defaultBucket'].forEach((field) => {
const element = document.getElementById(`node-input-${field}`); const element = document.getElementById(`node-input-${field}`);
if (!element) return; if (!element) return;
node[field] = field === 'port' ? parseInt(element.value, 10) || 3000 : element.value || ''; node[field] = field === 'port' ? parseInt(element.value, 10) || 3000 : element.value || '';
}); });
// bearerToken is handled by Node-RED's credentials system (encrypted at rest in flow_cred.json).
}, },
}); });
</script> </script>
@@ -80,7 +85,17 @@
<div class="form-row"> <div class="form-row">
<label for="node-input-bearerToken"><i class="fa fa-key"></i> Bearer Token</label> <label for="node-input-bearerToken"><i class="fa fa-key"></i> Bearer Token</label>
<input type="password" id="node-input-bearerToken" placeholder="optional" style="width:70%;" /> <input type="password" id="node-input-bearerToken" placeholder="encrypted at rest" style="width:70%;" />
</div>
<div class="form-row">
<label for="node-input-folderTitle"><i class="fa fa-folder"></i> Grafana Folder</label>
<input type="text" id="node-input-folderTitle" placeholder="folder name e.g. EVOLV — resolved/created by name" style="width:70%;" />
</div>
<div class="form-row">
<label for="node-input-folderUid"><i class="fa fa-folder-open"></i> Grafana Folder UID</label>
<input type="text" id="node-input-folderUid" placeholder="optional fallback — leave empty when Folder name is set" style="width:70%;" />
</div> </div>
<div class="form-row"> <div class="form-row">

View File

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

View File

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

View File

@@ -1,10 +1,13 @@
{ {
"name": "dashboardAPI", "name": "dashboardAPI",
"version": "1.0.0", "version": "1.1.0",
"description": "EVOLV Grafana dashboard generator (Node-RED node).", "description": "EVOLV Grafana dashboard generator (Node-RED node).",
"main": "dashboardapi.js", "main": "dashboardAPI.js",
"scripts": { "scripts": {
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js" "test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js",
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
}, },
"keywords": [ "keywords": [
"dashboard", "dashboard",
@@ -19,7 +22,7 @@
}, },
"node-red": { "node-red": {
"nodes": { "nodes": {
"dashboardapi": "dashboardapi.js" "dashboardapi": "dashboardAPI.js"
} }
} }
} }

View File

@@ -22,14 +22,9 @@ function resolveChildNode(childId, ctx) {
return runtimeNode || flowNode || null; return runtimeNode || flowNode || null;
} }
// On child.register: build the dashboard graph (root + direct children) and // Shared emit path used by both child.register (auto, deploy-driven) and
// emit one Grafana upsert HTTP request per dashboard on Port 0. // regenerate-dashboard (manual). `trigger` distinguishes the two for logs.
function registerChild(source, msg, ctx) { async function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
const childSource = resolveChildSource(msg.payload, ctx);
if (!childSource?.config) {
throw new Error('Missing or invalid child node');
}
const dashboards = source.generateDashboardsForGraph(childSource, { const dashboards = source.generateDashboardsForGraph(childSource, {
includeChildren: Boolean(msg.includeChildren ?? true), includeChildren: Boolean(msg.includeChildren ?? true),
}); });
@@ -39,7 +34,27 @@ function registerChild(source, msg, ctx) {
const token = source.config?.grafanaConnector?.bearerToken; const token = source.config?.grafanaConnector?.bearerToken;
if (token) headers.Authorization = `Bearer ${token}`; if (token) headers.Authorization = `Bearer ${token}`;
// Resolve the folder by name (creating it if missing) so a rebuilt Grafana's
// fresh folder uid never strands the upserts on a stale pinned uid. Falls
// back to the configured folderUid on any failure.
const folderUid = typeof source.resolveFolderUid === 'function'
? await source.resolveFolderUid()
: (source.config?.grafanaConnector?.folderUid || undefined);
// Resolve the InfluxDB datasource uid by querying the target Grafana, then
// rewrite every panel/target/variable on each dashboard. Templates ship a
// hardcoded uid that only matches the Grafana they were authored against;
// without this rewrite a fresh Grafana renders every panel as
// "Datasource <uid> not found". Failure is non-fatal: rewriteDatasourceUid
// is a no-op when uid is empty, so panels keep their template uid.
const datasourceUid = typeof source.resolveDatasourceUid === 'function'
? await source.resolveDatasourceUid()
: '';
for (const dash of dashboards) { for (const dash of dashboards) {
if (datasourceUid && typeof source.rewriteDatasourceUid === 'function') {
source.rewriteDatasourceUid(dash.dashboard, datasourceUid);
}
ctx.send({ ctx.send({
...msg, ...msg,
topic: 'create', topic: 'create',
@@ -48,7 +63,7 @@ function registerChild(source, msg, ctx) {
headers, headers,
payload: source.buildUpsertRequest({ payload: source.buildUpsertRequest({
dashboard: dash.dashboard, dashboard: dash.dashboard,
folderId: 0, folderUid: folderUid || undefined,
overwrite: true, overwrite: true,
}), }),
meta: { meta: {
@@ -56,9 +71,73 @@ function registerChild(source, msg, ctx) {
softwareType: dash.softwareType, softwareType: dash.softwareType,
uid: dash.uid, uid: dash.uid,
title: dash.title, 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,
});
}
} }
module.exports = { registerChild }; // On child.register: build the dashboard graph (root + direct children) and
// emit one Grafana upsert HTTP request per dashboard on Port 0.
//
// Diff-skip behavior (PRD F-1, S1 spike #32): if the latest flows:started
// payload's `diff` indicates that NEITHER the dashboardAPI itself NOR this
// child NOR its grandchildren changed, skip composition and log no-diff. The
// first call after startup (no cached diff yet) regenerates unconditionally.
async function registerChild(source, msg, ctx) {
const childSource = resolveChildSource(msg.payload, ctx);
if (!childSource?.config) {
throw new Error('Missing or invalid child node');
}
// Cache the child source for later manual regen (#41).
source.recordChild?.(childSource);
const subtreeIds = source.subtreeIdsFor(ctx.node?.id, childSource);
const changed = source.subtreeChanged(source.lastFlowsStartedDiff, subtreeIds);
if (!changed) {
if (source.logger?.info) {
source.logger.info({
event: 'regen-skipped',
outcome: 'no-diff',
trigger: 'child.register',
dashboardApiId: ctx.node?.id,
childId: childSource?.config?.general?.id,
subtreeSize: subtreeIds.size,
});
}
return;
}
await emitDashboardsFor(source, childSource, ctx, msg, 'child.register');
}
// On regenerate-dashboard: re-emit dashboards for every cached child source,
// bypassing the diff predicate. Useful as an operator escape hatch when
// auto-regen missed an edge case or when the operator just wants to refresh.
async function regenerateDashboard(source, msg, ctx) {
const cached = source.cachedChildSources?.() || [];
if (source.logger?.info) {
source.logger.info({
event: 'manual-regen-requested',
trigger: 'manual',
dashboardApiId: ctx.node?.id,
cachedChildCount: cached.length,
});
}
for (const childSource of cached) {
await emitDashboardsFor(source, childSource, ctx, msg, 'manual');
}
}
module.exports = { registerChild, regenerateDashboard };

View File

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

View File

@@ -26,10 +26,45 @@ class nodeClass {
this._attachInputHandler(); this._attachInputHandler();
this._attachCloseHandler(); this._attachCloseHandler();
this._attachLifecycleHook();
}
// Subscribe to Node-RED's `flows:started` event to cache the deploy diff so
// the child.register handler can decide whether *this* dashboardAPI's
// subtree was affected. Predicate documented in Gitea issue #32 spike.
_attachLifecycleHook() {
if (!this.RED?.events?.on) return;
this._flowsStartedListener = (payload) => {
const diff = payload?.diff || null;
this.source.lastFlowsStartedDiff = diff;
this.source.lastFlowsStartedAt = Date.now();
if (this.source?.logger?.debug) {
const summary = diff
? Object.fromEntries(
['added', 'changed', 'removed', 'rewired', 'linked', 'flowChanged']
.map((k) => [k, (diff[k] || []).length])
)
: null;
this.source.logger.debug({ event: 'flows:started', type: payload?.type, diff: summary });
}
};
this.RED.events.on('flows:started', this._flowsStartedListener);
} }
_buildConfig(uiConfig) { _buildConfig(uiConfig) {
const cfgMgr = new configManager(); const cfgMgr = new configManager();
// Credentials block (Node-RED encrypts at rest in flow_cred.json). Legacy
// installs may still carry bearerToken on uiConfig — fall back with a
// one-time deprecation warning so the user knows to re-save.
const credentialToken = this.node?.credentials?.bearerToken || '';
const legacyToken = uiConfig.bearerToken || '';
if (!credentialToken && legacyToken) {
this.RED?.log?.warn?.(
`[${this.name}] bearer token loaded from legacy plain config field. ` +
`Re-open this node in the editor and click Done to migrate to encrypted credentials.`
);
}
const bearerToken = credentialToken || legacyToken;
return cfgMgr.buildConfig(this.name, uiConfig, this.node.id, { return cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
functionality: { functionality: {
softwareType: this.name.toLowerCase(), softwareType: this.name.toLowerCase(),
@@ -39,7 +74,9 @@ class nodeClass {
protocol: uiConfig.protocol || 'http', protocol: uiConfig.protocol || 'http',
host: uiConfig.host || 'localhost', host: uiConfig.host || 'localhost',
port: Number(uiConfig.port || 3000), port: Number(uiConfig.port || 3000),
bearerToken: uiConfig.bearerToken || '', bearerToken,
folderTitle: uiConfig.folderTitle || '',
folderUid: uiConfig.folderUid || '',
}, },
defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '', defaultBucket: uiConfig.defaultBucket || process.env.INFLUXDB_BUCKET || '',
}); });
@@ -65,6 +102,10 @@ class nodeClass {
_attachCloseHandler() { _attachCloseHandler() {
this.node.on('close', (done) => { this.node.on('close', (done) => {
if (this._flowsStartedListener && this.RED?.events?.off) {
this.RED.events.off('flows:started', this._flowsStartedListener);
this._flowsStartedListener = null;
}
if (typeof done === 'function') done(); if (typeof done === 'function') done();
}); });
} }

View File

@@ -18,6 +18,28 @@ function slugify(input) {
.slice(0, 60); .slice(0, 60);
} }
// Map a node's lowercased softwareType to its Grafana template file in config/.
// Nodes report softwareType as the lowercased node name (e.g. 'rotatingmachine',
// 'machinegroupcontrol'), but several template files are camelCase and some node
// types share a template (rotatingMachine → machine, diffuser → aeration). The
// keys here are always lowercase; lookup lowercases the input first.
const TEMPLATE_FILE_BY_SOFTWARE_TYPE = {
rotatingmachine: 'machine.json',
machine: 'machine.json',
machinegroupcontrol: 'machineGroup.json',
machinegroup: 'machineGroup.json',
pumpingstation: 'pumpingStation.json',
valvegroupcontrol: 'valveGroupControl.json',
diffuser: 'aeration.json',
aeration: 'aeration.json',
measurement: 'measurement.json',
monster: 'monster.json',
reactor: 'reactor.json',
settler: 'settler.json',
valve: 'valve.json',
dashboardapi: 'dashboardapi.json',
};
function defaultBucketForPosition(positionVsParent) { function defaultBucketForPosition(positionVsParent) {
const pos = String(positionVsParent || '').toLowerCase(); const pos = String(positionVsParent || '').toLowerCase();
if (pos === 'upstream') return 'lvl1'; if (pos === 'upstream') return 'lvl1';
@@ -25,6 +47,20 @@ function defaultBucketForPosition(positionVsParent) {
return 'lvl2'; return 'lvl2';
} }
// Replace `{{name}}` placeholders in a raw JSON template string with values
// from `vars`. Unknown placeholders are left intact. Used to inject node-config
// derived constants (basin geometry, threshold y-positions) into a template
// before JSON.parse — so the resulting dashboard has concrete numbers in
// fieldConfig.thresholds and canvas element placements. Mustache-style braces
// keep these placeholders distinct from Grafana's own `${var}` dashboard
// variables (which are interpreted by Grafana at render time).
function substituteTemplateVars(rawJson, vars) {
if (!vars || !Object.keys(vars).length) return rawJson;
return rawJson.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (m, key) => (
Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : m
));
}
function updateTemplatingVar(dashboard, varName, value) { function updateTemplatingVar(dashboard, varName, value) {
const list = dashboard?.templating?.list; const list = dashboard?.templating?.list;
if (!Array.isArray(list)) return; if (!Array.isArray(list)) return;
@@ -55,7 +91,7 @@ class DashboardApi {
general: { general: {
name: config?.general?.name || 'dashboardapi', name: config?.general?.name || 'dashboardapi',
logging: { logging: {
enabled: Boolean(config?.general?.logging?.enabled), enabled: config?.general?.logging?.enabled ?? true,
logLevel: config?.general?.logging?.logLevel || 'info', logLevel: config?.general?.logging?.logLevel || 'info',
}, },
}, },
@@ -64,6 +100,13 @@ class DashboardApi {
host: config?.grafanaConnector?.host || 'localhost', host: config?.grafanaConnector?.host || 'localhost',
port: Number(config?.grafanaConnector?.port || 3000), port: Number(config?.grafanaConnector?.port || 3000),
bearerToken: config?.grafanaConnector?.bearerToken || '', bearerToken: config?.grafanaConnector?.bearerToken || '',
// folderTitle is the durable way to target a folder: Grafana folder
// uids change whenever the instance is rebuilt, so a pinned folderUid
// goes stale (every upsert then 400s "folder not found"). When set, the
// uid is resolved (and the folder created if absent) by name at emit
// time. folderUid stays supported as an explicit override / fallback.
folderTitle: config?.grafanaConnector?.folderTitle || '',
folderUid: config?.grafanaConnector?.folderUid || '',
}, },
defaultBucket: config?.defaultBucket || '', defaultBucket: config?.defaultBucket || '',
bucketMap: config?.bucketMap || {}, bucketMap: config?.bucketMap || {},
@@ -74,6 +117,20 @@ class DashboardApi {
this.config.general.logging.logLevel, this.config.general.logging.logLevel,
this.config.general.name this.config.general.name
); );
// Light state cache for manual regen (#41). Stores the latest child
// source object per child id so `regenerate-dashboard` can re-emit
// dashboards without waiting for children to re-register.
this._lastChildSources = new Map();
}
recordChild(childSource) {
const id = childSource?.config?.general?.id;
if (id) this._lastChildSources.set(id, childSource);
}
cachedChildSources() {
return Array.from(this._lastChildSources.values());
} }
_templatesDir() { _templatesDir() {
@@ -83,9 +140,9 @@ class DashboardApi {
_templateFileForSoftwareType(softwareType) { _templateFileForSoftwareType(softwareType) {
const st = String(softwareType || '').trim(); const st = String(softwareType || '').trim();
const candidates = [ const candidates = [
TEMPLATE_FILE_BY_SOFTWARE_TYPE[st.toLowerCase()],
`${st}.json`, `${st}.json`,
`${st.toLowerCase()}.json`, `${st.toLowerCase()}.json`,
st === 'machineGroupControl' ? 'machineGroup.json' : null,
].filter(Boolean); ].filter(Boolean);
for (const filename of candidates) { for (const filename of candidates) {
@@ -97,29 +154,372 @@ class DashboardApi {
return null; return null;
} }
loadTemplate(softwareType) { loadTemplate(softwareType, templateVars = null) {
const templatePath = this._templateFileForSoftwareType(softwareType); const templatePath = this._templateFileForSoftwareType(softwareType);
if (!templatePath) return null; if (!templatePath) return null;
const raw = fs.readFileSync(templatePath, 'utf8'); let raw = fs.readFileSync(templatePath, 'utf8');
// Always substitute — falls back to per-softwareType defaults so the
// template is JSON-parseable even when no nodeConfig is provided (tests,
// smoke-loading, etc.). _templateVarsForNode returns {} for types that
// don't use placeholders, which is a no-op pass.
const vars = templateVars || this._templateVarsForNode(softwareType, null);
raw = substituteTemplateVars(raw, vars);
return JSON.parse(raw); return JSON.parse(raw);
} }
// Per-softwareType numeric vars baked into the template before JSON.parse.
// Today only pumpingStation needs this (basin geometry → bar-gauge thresholds
// and canvas y-positions). Other types return {} and skip substitution.
_templateVarsForNode(softwareType, nodeConfig) {
const st = String(softwareType || '').toLowerCase();
if (st !== 'pumpingstation') return {};
// configManager.buildConfig nests basin geometry under `basin.*` and
// safety percentages under `safety.*` (see generalFunctions/configManager).
const basin = nodeConfig?.basin || {};
const safety = nodeConfig?.safety || {};
const heightBasin = Number(basin.height) || 4;
const inflowLevel = Number(basin.inflowLevel) || 0;
const outflowLevel = Number(basin.outflowLevel) || 0;
const overflowLevel = Number(basin.overflowLevel) || heightBasin;
const dryRunPct = Number(safety.dryRunThresholdPercent) || 30;
const highPct = Number(safety.highVolumeSafetyThresholdPercent) || 90;
// Mirror specificClass._computeSafetyPoints derivation (pumpingStation).
const dryRunLevel = outflowLevel * (1 + dryRunPct / 100);
const highSafetyLevel = overflowLevel * (highPct / 100);
// Reference frame: 400 (logical w) x 760 (logical h) px. With every
// canvas element using `constraint: { horizontal: scale, vertical: scale }`,
// Grafana interprets placement values as PERCENTAGES of the panel size,
// not pixels — so the basin stretches to fill the card at any viewport
// and stays centered without letterboxing.
// Tank reference: rim at y=48px (6.32%), floor at y=712px (93.68%),
// centred vertically with 48px top/bottom margins. Margins are sized
// so the size-14 'rim (X m)' and 'floor (0.00 m)' captions fit with
// ~10 px clearance from the topmost/bottommost threshold line — labels
// can never collide with a line at any basin geometry.
const FRAME_W = 400, FRAME_H = 760;
const TANK_TOP = 48, TANK_BOT = 712, TANK_H = TANK_BOT - TANK_TOP;
const yp = (v) => +(v / FRAME_H * 100).toFixed(2);
const xp = (v) => +(v / FRAME_W * 100).toFixed(2);
const hp = (v) => +(v / FRAME_H * 100).toFixed(2);
const wp = (v) => +(v / FRAME_W * 100).toFixed(2);
const yFor = (v) => +(TANK_BOT - (v / heightBasin) * TANK_H).toFixed(2);
const tyFor = (yLine) => +(yLine - 8).toFixed(2); // centre 16px text on the line
let y_overflow = yFor(overflowLevel);
let y_highSafety = yFor(highSafetyLevel);
let y_inflow = yFor(inflowLevel);
let y_dryRun = yFor(dryRunLevel);
let y_outflow = yFor(outflowLevel);
// Enforce a minimum visual gap between adjacent threshold lines so labels
// can always sit cleanly between them — independent of how close the
// underlying physical thresholds are. Slight geometric distortion is
// acceptable: the tank visual conveys ORDERING and ZONE STRUCTURE, not
// exact-scale level measurement. Dashed/value labels carry the true
// numeric values.
const MIN_LINE_GAP = 28; // px (≈3.7% of 760-tall frame, > LABEL_H + 2)
const sorted = [
{ id: 'overflow', get: () => y_overflow, set: (v) => (y_overflow = v) },
{ id: 'highSafety', get: () => y_highSafety, set: (v) => (y_highSafety = v) },
{ id: 'inflow', get: () => y_inflow, set: (v) => (y_inflow = v) },
{ id: 'dryRun', get: () => y_dryRun, set: (v) => (y_dryRun = v) },
{ id: 'outflow', get: () => y_outflow, set: (v) => (y_outflow = v) },
].sort((a, b) => a.get() - b.get());
// Push down to enforce min gap (anchor: topmost line)
for (let i = 1; i < sorted.length; i++) {
const minY = sorted[i - 1].get() + MIN_LINE_GAP;
if (sorted[i].get() < minY) sorted[i].set(minY);
}
// If the last (lowest) line went past the floor, shift the whole stack up.
const overshoot = sorted[sorted.length - 1].get() - TANK_BOT;
if (overshoot > 0) {
for (const item of sorted) item.set(item.get() - overshoot);
}
// Label y-positions: labels sit either ABOVE or BELOW their threshold
// line, never on it. Each label is offset by ABOVE_OFFSET=22 px above
// its line by default (16 px tall label + 6 px clear above the line).
// If two thresholds are too close together for both labels to fit ABOVE
// their lines (label of the lower one would cross the upper line), the
// lower one's label flips BELOW its line instead. With the current
// basin (dryRun=2% means dryRunLevel sits right on outflowLevel; high-
// Safety=98% puts it just under overflowLevel) this naturally puts
// highSafety BELOW and outflow BELOW.
const LABEL_H = 16;
const ABOVE_OFFSET = 22; // label_top = line_y - 22 (6 px clear above line)
const BELOW_OFFSET = 6; // label_top = line_y + 6 (6 px clear below line)
const MIN_DIST_FOR_ABOVE = 24; // if distance to upper line < this, try below
const lines = [
{ id: 'overflow', line: y_overflow },
{ id: 'highSafety', line: y_highSafety },
{ id: 'inflow', line: y_inflow },
{ id: 'dryRun', line: y_dryRun },
{ id: 'outflow', line: y_outflow },
].sort((a, b) => a.line - b.line);
for (let i = 0; i < lines.length; i++) {
const prev = i > 0 ? lines[i - 1] : null;
const tooClose = prev && (lines[i].line - prev.line) < MIN_DIST_FOR_ABOVE;
if (tooClose) {
// Default to BELOW unless the label would be clipped by the tank
// floor (thresholds at the very bottom — dryRun=tiny% means
// dryRunLevel sits right on the floor). Then stack ABOVE the
// previous label instead, even if it slightly crowds its own line.
const belowY = lines[i].line + BELOW_OFFSET;
if (belowY + LABEL_H <= TANK_BOT) {
lines[i].y = belowY;
} else {
lines[i].y = prev.y + LABEL_H + 2; // stack above with 2 px gap
}
} else {
lines[i].y = lines[i].line - ABOVE_OFFSET;
}
}
const ty = Object.fromEntries(lines.map((l) => [l.id, +l.y.toFixed(2)]));
// Canvas elements use `constraint: { horizontal: scale, vertical: scale }`
// with margin-style placement (top + bottom + left + right, all %s of the
// panel). Bottom = % from panel bottom, top = % from panel top. Width and
// height are derived as 100 - top - bottom, etc.
// We emit *all* placement margins precomputed so the JSON template stays
// declarative.
const LABEL_H_PCT = hp(16); // 16 px label height as % of frame
const LINE_H_PCT = hp(1); // 1 px line height as % of frame
const bMargin = (top, h) => +(100 - top - h).toFixed(2);
const lineBottom = (lineY) => +(100 - yp(lineY) - LINE_H_PCT).toFixed(2);
const labelBottom = (lblY) => +(100 - yp(lblY) - LABEL_H_PCT).toFixed(2);
return {
heightBasin: +heightBasin.toFixed(2),
outflowLevel: +outflowLevel.toFixed(3),
inflowLevel: +inflowLevel.toFixed(3),
overflowLevel: +overflowLevel.toFixed(3),
dryRunLevel: +dryRunLevel.toFixed(3),
highSafetyLevel: +highSafetyLevel.toFixed(3),
// Threshold line top margins (% from panel top)
y_overflow: yp(y_overflow),
y_highSafety: yp(y_highSafety),
y_inflow: yp(y_inflow),
y_dryRun: yp(y_dryRun),
y_outflow: yp(y_outflow),
// Threshold line bottom margins (% from panel bottom)
yb_overflow: lineBottom(y_overflow),
yb_highSafety: lineBottom(y_highSafety),
yb_inflow: lineBottom(y_inflow),
yb_dryRun: lineBottom(y_dryRun),
yb_outflow: lineBottom(y_outflow),
// Zone bottom margins (zones end at the next line below)
zb_spill: +(100 - yp(y_overflow)).toFixed(2), // ends at overflow line
zb_highSafety: +(100 - yp(y_highSafety)).toFixed(2), // ends at highSafety line
zb_operating: +(100 - yp(y_outflow)).toFixed(2), // ends at outflow line
zb_dead: +(100 - yp(TANK_BOT)).toFixed(2), // ends at floor
// Label top margins (% from panel top) and bottom margins (% from panel bottom)
ty_overflow: yp(ty.overflow),
ty_highSafety: yp(ty.highSafety),
ty_inflow: yp(ty.inflow),
ty_dryRun: yp(ty.dryRun),
ty_outflow: yp(ty.outflow),
tyb_overflow: labelBottom(ty.overflow),
tyb_highSafety: labelBottom(ty.highSafety),
tyb_inflow: labelBottom(ty.inflow),
tyb_dryRun: labelBottom(ty.dryRun),
tyb_outflow: labelBottom(ty.outflow),
};
}
// Collect every `meta.emittedFields` declared by panels in a template.
// Used by #39's parent panel filter — a parent panel whose emittedFields
// are fully covered by its children's panels is removed.
collectEmittedFields(dashboard) {
const out = new Set();
for (const panel of dashboard?.panels || []) {
const fields = panel?.meta?.emittedFields;
if (Array.isArray(fields)) for (const f of fields) out.add(f);
}
return out;
}
grafanaUpsertUrl() { grafanaUpsertUrl() {
const { protocol, host, port } = this.config.grafanaConnector; const { protocol, host, port } = this.config.grafanaConnector;
return `${protocol}://${host}:${port}/api/dashboards/db`; return `${protocol}://${host}:${port}/api/dashboards/db`;
} }
grafanaFoldersUrl() {
const { protocol, host, port } = this.config.grafanaConnector;
return `${protocol}://${host}:${port}/api/folders`;
}
grafanaDatasourcesUrl() {
const { protocol, host, port } = this.config.grafanaConnector;
return `${protocol}://${host}:${port}/api/datasources`;
}
_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 '';
}
// Resolve the target Grafana InfluxDB datasource uid at push time. Templates
// ship with a hardcoded uid baked into every panel; that uid only matches the
// Grafana instance the templates were authored against. Any other Grafana
// (fresh laptop, VPS, rebuilt instance) renders the panels as
// "Datasource <uid> not found". Resolution is done once per process and
// cached.
//
// Degradation contract: any failure (no fetch, network error, non-OK
// response, no influxdb datasource present) returns '' and the caller leaves
// the template's baked-in uid alone. Worst-case behavior is unchanged from
// before this resolver existed.
async resolveDatasourceUid({ fetchImpl = globalThis.fetch } = {}) {
if (this._resolvedDatasourceUid) return this._resolvedDatasourceUid;
if (typeof fetchImpl !== 'function') {
this.logger.warn('resolveDatasourceUid: no fetch implementation available; leaving template uid intact');
return '';
}
try {
const uid = await this._lookupInfluxDatasource(fetchImpl);
if (uid) {
this._resolvedDatasourceUid = uid;
return uid;
}
} catch (err) {
this.logger.warn(`resolveDatasourceUid failed (${err?.message || err}); leaving template uid intact`);
}
return '';
}
async _lookupInfluxDatasource(fetchImpl) {
const url = this.grafanaDatasourcesUrl();
const headers = this._grafanaJsonHeaders();
const res = await fetchImpl(url, { method: 'GET', headers });
if (!res?.ok) {
this.logger.warn(`resolveDatasourceUid: GET /api/datasources -> ${res?.status}`);
return '';
}
const list = await res.json();
const match = Array.isArray(list) && list.find((d) => String(d?.type || '').toLowerCase() === 'influxdb');
if (match?.uid) {
this.logger.info({ event: 'datasource-resolved', outcome: 'found', name: match.name, uid: match.uid });
return match.uid;
}
this.logger.warn('resolveDatasourceUid: no influxdb datasource on target Grafana');
return '';
}
// Rewrite every influxdb datasource.uid on a dashboard (panels, nested row
// panels, panel.targets, templating variables) to `uid`. No-op for any
// datasource whose type isn't 'influxdb' (e.g. the '-- Grafana --' annotation
// datasource) or whose uid is a template variable reference (e.g.
// '${datasource}'). No-op when `uid` is falsy.
rewriteDatasourceUid(dashboard, uid) {
if (!uid || !dashboard) return;
const visit = (panels) => {
if (!Array.isArray(panels)) return;
for (const panel of panels) {
if (panel?.datasource && String(panel.datasource.type || '').toLowerCase() === 'influxdb'
&& typeof panel.datasource.uid === 'string' && !panel.datasource.uid.startsWith('$')) {
panel.datasource.uid = uid;
}
if (Array.isArray(panel?.targets)) {
for (const t of panel.targets) {
if (t?.datasource && String(t.datasource.type || '').toLowerCase() === 'influxdb'
&& typeof t.datasource.uid === 'string' && !t.datasource.uid.startsWith('$')) {
t.datasource.uid = uid;
}
}
}
visit(panel?.panels);
}
};
visit(dashboard.panels);
const tplList = dashboard?.templating?.list;
if (Array.isArray(tplList)) {
for (const v of tplList) {
if (v?.datasource && String(v.datasource.type || '').toLowerCase() === 'influxdb'
&& typeof v.datasource.uid === 'string' && !v.datasource.uid.startsWith('$')) {
v.datasource.uid = uid;
}
}
}
}
buildDashboard({ nodeConfig, positionVsParent }) { buildDashboard({ nodeConfig, positionVsParent }) {
const softwareType = const softwareType =
nodeConfig?.functionality?.softwareType || nodeConfig?.functionality?.softwareType ||
nodeConfig?.functionality?.software_type || nodeConfig?.functionality?.software_type ||
'measurement'; 'measurement';
const nodeId = nodeConfig?.general?.id || nodeConfig?.general?.name || softwareType; const nodeId = nodeConfig?.general?.id || nodeConfig?.general?.name || softwareType;
const measurementName = `${softwareType}_${nodeId}`; // Mirror outputUtils.formatMsg: telemetry is written under general.name when
// set, falling back to `<softwareType>_<id>`. The dashboard's _measurement var
// must match that exactly or every panel queries a non-existent series.
const measurementName =
nodeConfig?.general?.name || `${softwareType}_${nodeConfig?.general?.id || softwareType}`;
const title = nodeConfig?.general?.name || String(nodeId); const title = nodeConfig?.general?.name || String(nodeId);
// Missing templates are treated as non-fatal: we skip only that dashboard. // Missing templates are treated as non-fatal: we skip only that dashboard.
const dashboard = this.loadTemplate(softwareType); const templateVars = this._templateVarsForNode(softwareType, nodeConfig);
const dashboard = this.loadTemplate(softwareType, templateVars);
if (!dashboard) { if (!dashboard) {
this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`); this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`);
return null; return null;
@@ -141,11 +541,16 @@ class DashboardApi {
updateTemplatingVar(dashboard, 'measurement', measurementName); updateTemplatingVar(dashboard, 'measurement', measurementName);
updateTemplatingVar(dashboard, 'bucket', bucket); updateTemplatingVar(dashboard, 'bucket', bucket);
return { dashboard, uid, title, softwareType, nodeId, measurementName }; return { dashboard, uid, title, softwareType, nodeId, measurementName, bucket };
} }
buildUpsertRequest({ dashboard, folderId = 0, overwrite = true }) { buildUpsertRequest({ dashboard, folderId, folderUid, overwrite = true }) {
return { dashboard, folderId, overwrite }; const out = { dashboard, overwrite };
// Prefer folderUid (modern Grafana API). Fall back to folderId for older callers.
const uid = folderUid ?? this.config?.grafanaConnector?.folderUid ?? '';
if (uid) out.folderUid = uid;
else if (typeof folderId === 'number') out.folderId = folderId;
return out;
} }
extractChildren(nodeSource) { extractChildren(nodeSource) {
@@ -162,29 +567,125 @@ class DashboardApi {
return out; return out;
} }
// Predicate from Gitea issue #32 spike (S1 findings). Given the diff payload
// from Node-RED's flows:started event and a set of node ids that constitute
// "my subtree", decides whether the subtree changed on this deploy.
// `null` diff (first deploy / startup) → always regen (safe default).
subtreeChanged(diff, subtreeIds) {
if (!diff) return true;
const mine = new Set(subtreeIds);
for (const field of ['added', 'changed', 'removed', 'rewired']) {
const arr = diff[field] || [];
if (arr.some((id) => mine.has(id))) return true;
}
return false;
}
// Collect every node id in "this dashboardAPI + this child's full subtree" for
// the diff predicate. Recurses the whole registered-child tree (not just
// grandchildren) so a change anywhere below a wired root triggers a regen.
// `visited` guards cycles / diamond topologies.
subtreeIdsFor(dashboardApiNodeId, childSource) {
const ids = new Set();
if (dashboardApiNodeId) ids.add(dashboardApiNodeId);
this._collectSubtreeIds(childSource, ids, new Set());
return ids;
}
_collectSubtreeIds(nodeSource, ids, visited) {
const id = nodeSource?.config?.general?.id;
if (id) {
if (visited.has(id)) return;
visited.add(id);
ids.add(id);
}
for (const { childSource } of this.extractChildren(nodeSource)) {
this._collectSubtreeIds(childSource, ids, visited);
}
}
// Compose a dashboard for a wired root and EVERY descendant in its registered-
// child tree. Operators wire only subtree roots; dashboardAPI recurses the
// parent-child relationships to discover the rest. Returns a flat, pre-order
// array (root first) of buildDashboard results.
generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) { generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) {
if (!rootSource?.config) { if (!rootSource?.config) {
this.logger.warn('generateDashboardsForGraph skipped: root source missing config'); this.logger.warn('generateDashboardsForGraph skipped: root source missing config');
return []; return [];
} }
const rootPosition = rootSource?.positionVsParent || rootSource?.config?.functionality?.positionVsParent; const results = [];
const rootDash = this.buildDashboard({ nodeConfig: rootSource.config, positionVsParent: rootPosition }); this._composeNode(rootSource, includeChildren, results, new Set());
if (!rootDash) return []; return results;
const results = [rootDash];
if (!includeChildren) return results;
const children = this.extractChildren(rootSource);
for (const { childSource, positionVsParent } of children) {
const childDash = this.buildDashboard({ nodeConfig: childSource.config, positionVsParent });
if (childDash) results.push(childDash);
} }
// Add links from the root dashboard to children dashboards (when possible) // Recursively compose `nodeSource` then its descendants. Per-parent dedup and
if (children.length > 0) { // links are applied at every level (each parent is deduped against / links to
rootDash.dashboard.links = Array.isArray(rootDash.dashboard.links) ? rootDash.dashboard.links : []; // its own direct children). `visited` ensures one dashboard per node id even
// when the topology has cycles or diamonds.
_composeNode(nodeSource, includeChildren, results, visited) {
const nodeId = nodeSource?.config?.general?.id;
if (nodeId) {
if (visited.has(nodeId)) return null;
visited.add(nodeId);
}
const position = nodeSource?.positionVsParent || nodeSource?.config?.functionality?.positionVsParent;
const nodeDash = this.buildDashboard({ nodeConfig: nodeSource.config, positionVsParent: position });
if (!nodeDash) return null;
results.push(nodeDash);
if (!includeChildren) return nodeDash;
const children = this.extractChildren(nodeSource);
const childDashes = [];
for (const { childSource } of children) {
const childDash = this._composeNode(childSource, includeChildren, results, visited);
if (childDash) childDashes.push(childDash);
}
this._dedupParentPanels(nodeDash, childDashes);
this._linkToChildren(nodeDash, children);
// Inject the per-pump fan-out panels AFTER dedup so they survive: these
// panels intentionally aggregate child data onto the parent dashboard
// (the operator wants every pump on one MGC graph), which is exactly what
// the no-duplication rule strips elsewhere. Run last so nothing removes them.
this._injectMachineGroupPumpPanels(nodeDash, children);
return nodeDash;
}
// No-data-duplication rule (PRD F-5, #39): remove a parent's panels whose
// emittedFields are fully covered by its direct children's panels, so the
// same series isn't rendered twice across the parent/child dashboards.
_dedupParentPanels(parentDash, childDashes) {
if (childDashes.length === 0 || !parentDash.dashboard) return;
const childCoveredFields = new Set();
for (const dash of childDashes) {
for (const f of this.collectEmittedFields(dash.dashboard)) childCoveredFields.add(f);
}
const before = parentDash.dashboard.panels.length;
parentDash.dashboard.panels = parentDash.dashboard.panels.filter((p) => {
if (p.type === 'row') return true; // never drop rows
const fields = p?.meta?.emittedFields;
if (!Array.isArray(fields) || fields.length === 0) return true; // no declaration, keep
return !fields.every((f) => childCoveredFields.has(f));
});
if (this.logger?.debug && before !== parentDash.dashboard.panels.length) {
this.logger.debug({
event: 'parent-panels-deduped',
before,
after: parentDash.dashboard.panels.length,
rootTitle: parentDash.title,
});
}
}
_linkToChildren(parentDash, children) {
if (children.length === 0 || !parentDash.dashboard) return;
parentDash.dashboard.links = Array.isArray(parentDash.dashboard.links) ? parentDash.dashboard.links : [];
for (const { childSource } of children) { for (const { childSource } of children) {
const childConfig = childSource.config; const childConfig = childSource.config;
const childSoftwareType = childConfig?.functionality?.softwareType || 'measurement'; const childSoftwareType = childConfig?.functionality?.softwareType || 'measurement';
@@ -192,7 +693,7 @@ class DashboardApi {
const childUid = stableUid(`${childSoftwareType}:${childNodeId}`); const childUid = stableUid(`${childSoftwareType}:${childNodeId}`);
const childTitle = childConfig?.general?.name || String(childNodeId); const childTitle = childConfig?.general?.name || String(childNodeId);
rootDash.dashboard.links.push({ parentDash.dashboard.links.push({
type: 'link', type: 'link',
title: childTitle, title: childTitle,
url: `/d/${childUid}/${slugify(childTitle)}`, url: `/d/${childUid}/${slugify(childTitle)}`,
@@ -204,7 +705,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,43 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
test('buildUpsertRequest emits folderUid when configured', () => {
const api = new DashboardApi({
grafanaConnector: { folderUid: 'rnd-folder' },
});
const req = api.buildUpsertRequest({ dashboard: { uid: 'x', title: 'X' } });
assert.equal(req.folderUid, 'rnd-folder');
assert.equal(req.overwrite, true);
assert.ok(!('folderId' in req), 'should not emit folderId when folderUid is set');
});
test('buildUpsertRequest omits folderUid when empty (Grafana defaults to General)', () => {
const api = new DashboardApi({});
const req = api.buildUpsertRequest({ dashboard: { uid: 'x' } });
assert.equal(req.folderUid, undefined);
// folderId fallback only when explicitly passed
assert.equal(req.folderId, undefined);
});
test('buildUpsertRequest folderUid override at call-site wins over config', () => {
const api = new DashboardApi({ grafanaConnector: { folderUid: 'rnd-folder' } });
const req = api.buildUpsertRequest({ dashboard: { uid: 'x' }, folderUid: 'override-folder' });
assert.equal(req.folderUid, 'override-folder');
});
test('bearerToken from config flows into specificClass config', () => {
const api = new DashboardApi({
grafanaConnector: { bearerToken: 'tok-xyz', folderUid: '' },
});
assert.equal(api.config.grafanaConnector.bearerToken, 'tok-xyz');
});
test('default config has empty bearerToken and folderUid', () => {
const api = new DashboardApi({});
assert.equal(api.config.grafanaConnector.bearerToken, '');
assert.equal(api.config.grafanaConnector.folderUid, '');
});

View File

@@ -0,0 +1,75 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
function makeChild(i, softwareType = 'measurement', positionVsParent = 'downstream') {
return {
child: {
config: {
general: { id: `child-${i}`, name: `Child ${i}` },
functionality: { softwareType, positionVsParent },
},
},
softwareType,
position: positionVsParent,
registeredAt: Date.now(),
};
}
function makeRoot(children) {
const map = new Map();
for (const c of children) map.set(c.child.config.general.id, c);
return {
config: {
general: { id: 'root-1', name: 'Root' },
functionality: { softwareType: 'dashboardapi', positionVsParent: 'atequipment' },
},
childRegistrationUtils: { registeredChildren: map },
};
}
test('generateDashboardsForGraph composes 50 children in <500ms', () => {
const api = new DashboardApi({});
const children = Array.from({ length: 50 }, (_, i) => makeChild(i));
const root = makeRoot(children);
const t0 = process.hrtime.bigint();
const dashboards = api.generateDashboardsForGraph(root, { includeChildren: true });
const t1 = process.hrtime.bigint();
const durationMs = Number(t1 - t0) / 1e6;
assert.ok(durationMs < 500, `composition took ${durationMs.toFixed(1)}ms, expected <500ms`);
assert.ok(dashboards.length >= 1, 'should produce at least the root dashboard');
});
test('uids are unique across all generated dashboards (no collision risk)', () => {
const api = new DashboardApi({});
const children = Array.from({ length: 30 }, (_, i) => makeChild(i, 'measurement'));
const root = makeRoot(children);
const dashboards = api.generateDashboardsForGraph(root);
const uids = dashboards.map((d) => d.uid);
const unique = new Set(uids);
assert.equal(unique.size, uids.length, `expected ${uids.length} unique uids, got ${unique.size}`);
});
test('byte-identical composition under repeat (idempotency)', () => {
const api = new DashboardApi({});
const children = Array.from({ length: 5 }, (_, i) => makeChild(i));
const root = makeRoot(children);
const first = JSON.stringify(api.generateDashboardsForGraph(root).map((d) => d.dashboard));
const second = JSON.stringify(api.generateDashboardsForGraph(root).map((d) => d.dashboard));
assert.equal(first, second, 'two consecutive compositions should produce byte-identical JSON');
});
test('root dashboard links to every child dashboard', () => {
const api = new DashboardApi({});
const children = Array.from({ length: 4 }, (_, i) => makeChild(i));
const root = makeRoot(children);
const dashboards = api.generateDashboardsForGraph(root);
const rootDash = dashboards[0].dashboard;
assert.ok(Array.isArray(rootDash.links), 'root dashboard should have links array');
assert.equal(rootDash.links.length, 4, 'one link per registered child');
});

View File

@@ -0,0 +1,73 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
test('subtreeChanged: null diff → always regen (safe default for cold start)', () => {
const api = new DashboardApi({});
assert.equal(api.subtreeChanged(null, new Set(['a', 'b'])), true);
assert.equal(api.subtreeChanged(undefined, new Set(['a', 'b'])), true);
});
test('subtreeChanged: empty diff arrays → no regen needed', () => {
const api = new DashboardApi({});
const diff = { added: [], changed: [], removed: [], rewired: [], linked: [], flowChanged: [] };
assert.equal(api.subtreeChanged(diff, new Set(['a', 'b'])), false);
});
test('subtreeChanged: id in added → regen', () => {
const api = new DashboardApi({});
const diff = { added: ['x', 'b'], changed: [], removed: [], rewired: [] };
assert.equal(api.subtreeChanged(diff, new Set(['a', 'b'])), true);
});
test('subtreeChanged: id in changed → regen', () => {
const api = new DashboardApi({});
const diff = { added: [], changed: ['a'], removed: [], rewired: [] };
assert.equal(api.subtreeChanged(diff, new Set(['a', 'b'])), true);
});
test('subtreeChanged: only unrelated ids → no regen', () => {
const api = new DashboardApi({});
const diff = { added: ['z'], changed: ['y'], removed: ['x'], rewired: ['w'] };
assert.equal(api.subtreeChanged(diff, new Set(['a', 'b'])), false);
});
test('subtreeChanged: tab id in diff but not in subtree → no regen', () => {
// Tab id over-triggering avoidance: when an unrelated tab changes, its
// tab id lands in changed/added but should not affect this dashboardAPI.
const api = new DashboardApi({});
const diff = { added: [], changed: ['unrelated_tab'], removed: [], rewired: [] };
assert.equal(api.subtreeChanged(diff, new Set(['dashboardApiId', 'childA'])), false);
});
test('subtreeIdsFor: includes dashboardAPI id + child id + grandchild ids', () => {
const api = new DashboardApi({});
const grandchild = {
config: { general: { id: 'gc-1' }, functionality: { softwareType: 'measurement' } },
};
const grandchildEntry = { child: grandchild, position: 'downstream', softwareType: 'measurement' };
const child = {
config: { general: { id: 'child-1' }, functionality: { softwareType: 'pumpingStation' } },
childRegistrationUtils: {
registeredChildren: new Map([['gc-1', grandchildEntry]]),
},
};
const ids = api.subtreeIdsFor('dApi-1', child);
assert.equal(ids.has('dApi-1'), true);
assert.equal(ids.has('child-1'), true);
assert.equal(ids.has('gc-1'), true);
assert.equal(ids.size, 3);
});
test('subtreeIdsFor: handles child with no grandchildren', () => {
const api = new DashboardApi({});
const child = {
config: { general: { id: 'child-1' }, functionality: { softwareType: 'measurement' } },
};
const ids = api.subtreeIdsFor('dApi-1', child);
assert.equal(ids.size, 2);
assert.ok(ids.has('dApi-1') && ids.has('child-1'));
});

View File

@@ -0,0 +1,40 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
test('rotatingMachine template panels declare meta.emittedFields', () => {
const api = new DashboardApi({});
const dash = api.loadTemplate('machine');
assert.ok(dash, 'template loaded');
const withFields = dash.panels.filter((p) => p?.meta?.emittedFields);
// 13 non-row panels in machine.json get annotated; row panels are skipped.
assert.ok(withFields.length >= 10, `expected ≥10 annotated panels, got ${withFields.length}`);
});
test('collectEmittedFields aggregates fields across panels', () => {
const api = new DashboardApi({});
const dash = api.loadTemplate('machine');
const fields = api.collectEmittedFields(dash);
assert.ok(fields.has('ctrl'), 'ctrl field declared by Ctrl % panel');
assert.ok(fields.has('flow'), 'flow field declared by Flow panel');
assert.ok(fields.has('efficiency'), 'efficiency field declared by Efficiency panel');
assert.ok(fields.has('relDistFromPeak'), 'relDistFromPeak declared by Distance from Peak panel');
});
test('collectEmittedFields returns empty Set for template without meta', () => {
const api = new DashboardApi({});
// measurement.json has no emittedFields metadata yet — its panels predate the annotation.
const dash = api.loadTemplate('measurement');
const fields = api.collectEmittedFields(dash);
assert.equal(fields.size, 0);
});
test('collectEmittedFields handles null/empty dashboard input gracefully', () => {
const api = new DashboardApi({});
assert.equal(api.collectEmittedFields(null).size, 0);
assert.equal(api.collectEmittedFields({}).size, 0);
assert.equal(api.collectEmittedFields({ panels: [] }).size, 0);
});

View File

@@ -0,0 +1,43 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
test('rotatingMachine template carries byRegexp dashed overrides for .min/.max', () => {
const api = new DashboardApi({});
const dash = api.loadTemplate('machine');
const ts = dash.panels.filter((p) => p.type === 'timeseries');
assert.ok(ts.length >= 1, 'has at least one timeseries panel');
for (const panel of ts) {
const overrides = panel?.fieldConfig?.overrides || [];
const minOv = overrides.find(
(o) => o.matcher?.id === 'byRegexp' && /\.min\$/.test(o.matcher?.options || '')
);
const maxOv = overrides.find(
(o) => o.matcher?.id === 'byRegexp' && /\.max\$/.test(o.matcher?.options || '')
);
assert.ok(minOv, `panel "${panel.title}" missing .min override`);
assert.ok(maxOv, `panel "${panel.title}" missing .max override`);
const lineStyle = minOv.properties.find((p) => p.id === 'custom.lineStyle');
assert.equal(lineStyle?.value?.fill, 'dash', '.min override sets dashed lineStyle');
assert.deepEqual(lineStyle?.value?.dash, [10, 10], '.min override sets dash pattern [10,10]');
}
});
test('dashed overrides are forward-compatible: no effect when fields absent', () => {
// The byRegexp matcher only affects series whose name ends in .min/.max.
// When the node doesn't emit those fields, the override has no effect on
// the rendered panel — series simply don't appear. Verified by the
// matcher pattern being a strict regex.
const api = new DashboardApi({});
const dash = api.loadTemplate('machine');
const ts = dash.panels.filter((p) => p.type === 'timeseries')[0];
const minOv = ts.fieldConfig.overrides.find(
(o) => o.matcher?.id === 'byRegexp' && /\.min\$/.test(o.matcher.options || '')
);
assert.match(minOv.matcher.options, /\$$/, 'matcher anchored to end of name');
});

View File

@@ -0,0 +1,102 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
function makeChild(id, softwareType) {
return {
config: {
general: { id, name: id },
functionality: { softwareType, positionVsParent: 'downstream' },
},
};
}
function makeRoot(softwareType, children) {
const map = new Map();
for (const c of children) {
map.set(c.config.general.id, {
child: c,
softwareType: c.config.functionality.softwareType,
position: 'downstream',
});
}
return {
config: {
general: { id: 'root-1', name: 'PS-North' },
functionality: { softwareType, positionVsParent: 'atequipment' },
},
childRegistrationUtils: { registeredChildren: map },
};
}
test('pumpingStation template has emittedFields on every non-row panel', () => {
const api = new DashboardApi({});
const dash = api.loadTemplate('pumpingStation');
const annotated = dash.panels.filter((p) => p.type !== 'row' && p?.meta?.emittedFields);
const nonRowPanels = dash.panels.filter((p) => p.type !== 'row');
assert.equal(annotated.length, nonRowPanels.length,
`expected all ${nonRowPanels.length} non-row panels annotated, got ${annotated.length}`);
});
test('child-covered fields remove duplicate parent panels', () => {
const api = new DashboardApi({});
// Parent + 1 child with a fake template that emits 'level' (matches one of
// the pumpingStation parent's panels). The parent's "Level" panel should
// be removed when the child covers it.
const child1 = makeChild('child-1', 'measurement');
const root = makeRoot('pumpingStation', [child1]);
// Pre-count parent panels with the 'level' emitted field.
const parentTemplate = api.loadTemplate('pumpingStation');
const parentLevelPanels = parentTemplate.panels.filter(
(p) => p?.meta?.emittedFields?.includes('level')
);
assert.ok(parentLevelPanels.length > 0, 'parent has level panels in template');
// Monkey-patch the child's dashboard to claim it covers 'level'.
const origLoad = api.loadTemplate.bind(api);
api.loadTemplate = function (type) {
const dash = origLoad(type);
if (type === 'measurement' && dash) {
// Inject emittedFields = ['level'] on first non-row panel.
const firstPanel = dash.panels.find((p) => p.type !== 'row');
if (firstPanel) (firstPanel.meta ||= {}).emittedFields = ['level'];
}
return dash;
};
const result = api.generateDashboardsForGraph(root);
const rootResult = result[0];
const rootLevelPanels = rootResult.dashboard.panels.filter(
(p) => p?.meta?.emittedFields?.includes('level')
);
assert.equal(rootLevelPanels.length, 0,
'level panel(s) should be removed from parent when child covers them');
});
test('parent panels are kept when no child covers their fields', () => {
const api = new DashboardApi({});
const child1 = makeChild('child-1', 'measurement'); // measurement.json has no emittedFields
const root = makeRoot('pumpingStation', [child1]);
const result = api.generateDashboardsForGraph(root);
const rootResult = result[0];
const beforeTemplate = api.loadTemplate('pumpingStation');
const beforeNonRow = beforeTemplate.panels.filter((p) => p.type !== 'row').length;
const afterNonRow = rootResult.dashboard.panels.filter((p) => p.type !== 'row').length;
assert.equal(afterNonRow, beforeNonRow,
'no panels should be removed when no child declares overlapping fields');
});
test('row panels are never removed (structural)', () => {
const api = new DashboardApi({});
const child1 = makeChild('child-1', 'measurement');
const root = makeRoot('pumpingStation', [child1]);
const result = api.generateDashboardsForGraph(root);
const rootRows = result[0].dashboard.panels.filter((p) => p.type === 'row');
const templateRows = api.loadTemplate('pumpingStation').panels.filter((p) => p.type === 'row');
assert.equal(rootRows.length, templateRows.length, 'all row panels preserved');
});

View File

@@ -0,0 +1,69 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
test('MGC template panels are all group-level (no per-pump fields)', () => {
const api = new DashboardApi({});
const dash = api.loadTemplate('machineGroup');
const PER_PUMP = new Set(['ctrl', 'state', 'runtime', 'pressure.upstream', 'pressure.downstream', 'temperature']);
for (const panel of dash.panels || []) {
if (panel.type === 'row') continue;
const fields = panel?.meta?.emittedFields || [];
for (const f of fields) {
assert.ok(!PER_PUMP.has(f),
`MGC panel "${panel.title}" emits ${f}, which belongs to rotatingMachine (per-pump). Move to children.`);
}
}
});
test('MGC group panels are annotated (mode, scaling, abs/rel peak, totals)', () => {
const api = new DashboardApi({});
const dash = api.loadTemplate('machineGroup');
const non = dash.panels.filter((p) => p.type !== 'row');
const annotated = non.filter((p) => p?.meta?.emittedFields);
assert.equal(annotated.length, non.length, 'every non-row MGC panel annotated');
});
test('MGC timeseries panels carry dashed-bounds overrides for .min/.max', () => {
const api = new DashboardApi({});
const dash = api.loadTemplate('machineGroup');
const ts = dash.panels.filter((p) => p.type === 'timeseries');
for (const panel of ts) {
const ov = panel?.fieldConfig?.overrides || [];
const hasMin = ov.some((o) => o.matcher?.id === 'byRegexp' && /\.min\$/.test(o.matcher?.options || ''));
const hasMax = ov.some((o) => o.matcher?.id === 'byRegexp' && /\.max\$/.test(o.matcher?.options || ''));
assert.ok(hasMin && hasMax, `MGC ts panel "${panel.title}" missing .min/.max dashed override`);
}
});
test('MGC composer dedups parent panels covered by pump children', () => {
// If a rotatingMachine child claims to emit `flow.total` (it shouldn't, but
// suppose), the parent MGC's "Total Flow" panel would be removed. Verify
// the composer applies the same dedup rule to MGC parents.
const api = new DashboardApi({});
function makeChildSrc(id) {
return { config: { general: { id }, functionality: { softwareType: 'machine', positionVsParent: 'downstream' } } };
}
const child = makeChildSrc('pump-1');
const root = {
config: { general: { id: 'mgc-1', name: 'MGC' }, functionality: { softwareType: 'machineGroupControl', positionVsParent: 'atequipment' } },
childRegistrationUtils: { registeredChildren: new Map([['pump-1', { child, position: 'downstream', softwareType: 'machine' }]]) },
};
const origLoad = api.loadTemplate.bind(api);
api.loadTemplate = function (t) {
const dash = origLoad(t);
if (t === 'machine') {
// Make the pump's template falsely claim it emits flow.total/flow.group
const firstPanel = dash.panels.find((p) => p.type !== 'row');
if (firstPanel) (firstPanel.meta ||= {}).emittedFields = ['flow.total', 'flow.group'];
}
return dash;
};
const results = api.generateDashboardsForGraph(root);
const mgcDash = results[0].dashboard;
const totalFlowPanel = mgcDash.panels.find((p) => p.title === 'Total Flow');
assert.ok(!totalFlowPanel, 'MGC Total Flow panel should be removed when child claims flow.total/flow.group');
});

View File

@@ -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

@@ -0,0 +1,174 @@
'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');
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('resolveDatasourceUid returns the first influxdb datasource uid', async () => {
const a = api();
const fetchImpl = makeFetch({
'GET /api/datasources': {
body: [
{ type: 'prometheus', uid: 'p1' },
{ type: 'influxdb', uid: 'dfmpjg9jjvym8b', name: 'influxdb' },
{ type: 'influxdb', uid: 'second-one' },
],
},
});
const uid = await a.resolveDatasourceUid({ fetchImpl });
assert.equal(uid, 'dfmpjg9jjvym8b');
});
test('resolveDatasourceUid is cached → second call makes no further fetch', async () => {
const a = api();
const fetchImpl = makeFetch({
'GET /api/datasources': { body: [{ type: 'influxdb', uid: 'u1' }] },
});
await a.resolveDatasourceUid({ fetchImpl });
await a.resolveDatasourceUid({ fetchImpl });
assert.equal(fetchImpl.calls.length, 1);
});
test('resolveDatasourceUid returns empty string when no influxdb datasource exists', async () => {
const a = api();
const fetchImpl = makeFetch({
'GET /api/datasources': { body: [{ type: 'prometheus', uid: 'p1' }] },
});
const uid = await a.resolveDatasourceUid({ fetchImpl });
assert.equal(uid, '');
});
test('resolveDatasourceUid: fetch throws → returns empty string (template uid preserved)', async () => {
const a = api();
const fetchImpl = async () => { throw new Error('ECONNREFUSED'); };
const uid = await a.resolveDatasourceUid({ fetchImpl });
assert.equal(uid, '');
});
test('resolveDatasourceUid: no fetch available → returns empty string', async () => {
const a = api();
const uid = await a.resolveDatasourceUid({ fetchImpl: null });
assert.equal(uid, '');
});
test('rewriteDatasourceUid: rewrites panel.datasource.uid for influxdb only', () => {
const a = api();
const dashboard = {
panels: [
{ datasource: { type: 'influxdb', uid: 'OLD' } },
{ datasource: { type: 'grafana', uid: '-- Grafana --' } },
],
};
a.rewriteDatasourceUid(dashboard, 'NEW');
assert.equal(dashboard.panels[0].datasource.uid, 'NEW');
assert.equal(dashboard.panels[1].datasource.uid, '-- Grafana --');
});
test('rewriteDatasourceUid: rewrites panel.targets[].datasource.uid', () => {
const a = api();
const dashboard = {
panels: [
{
datasource: { type: 'influxdb', uid: 'OLD' },
targets: [
{ datasource: { type: 'influxdb', uid: 'OLD' }, query: 'a' },
{ datasource: { type: 'influxdb', uid: 'OLD' }, query: 'b' },
],
},
],
};
a.rewriteDatasourceUid(dashboard, 'NEW');
for (const t of dashboard.panels[0].targets) assert.equal(t.datasource.uid, 'NEW');
});
test('rewriteDatasourceUid: descends into nested row panels', () => {
const a = api();
const dashboard = {
panels: [
{
type: 'row',
panels: [
{ datasource: { type: 'influxdb', uid: 'OLD' } },
],
},
],
};
a.rewriteDatasourceUid(dashboard, 'NEW');
assert.equal(dashboard.panels[0].panels[0].datasource.uid, 'NEW');
});
test('rewriteDatasourceUid: rewrites templating.list[] influxdb variables', () => {
const a = api();
const dashboard = {
panels: [],
templating: {
list: [
{ type: 'query', datasource: { type: 'influxdb', uid: 'OLD' } },
{ type: 'constant', datasource: { type: 'prometheus', uid: 'OLD' } },
],
},
};
a.rewriteDatasourceUid(dashboard, 'NEW');
assert.equal(dashboard.templating.list[0].datasource.uid, 'NEW');
assert.equal(dashboard.templating.list[1].datasource.uid, 'OLD');
});
test('rewriteDatasourceUid: leaves template-variable references alone (${datasource})', () => {
const a = api();
const dashboard = {
panels: [{ datasource: { type: 'influxdb', uid: '${datasource}' } }],
};
a.rewriteDatasourceUid(dashboard, 'NEW');
assert.equal(dashboard.panels[0].datasource.uid, '${datasource}');
});
test('rewriteDatasourceUid: no-op when uid is falsy (preserves template)', () => {
const a = api();
const dashboard = { panels: [{ datasource: { type: 'influxdb', uid: 'KEEP' } }] };
a.rewriteDatasourceUid(dashboard, '');
assert.equal(dashboard.panels[0].datasource.uid, 'KEEP');
});
test('emit path rewrites every upsert dashboard with the resolved datasource uid', async () => {
const a = api({ folderTitle: 'EVOLV' });
a.resolveFolderUid = async () => 'fld';
a.resolveDatasourceUid = async () => 'resolved-ds-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);
for (const m of sent) {
const panels = m.payload?.dashboard?.panels || [];
for (const p of panels) {
if (p?.datasource?.type === 'influxdb') {
assert.equal(p.datasource.uid, 'resolved-ds-uid');
}
}
}
});

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/basic.flow.json'), 'utf8')); const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/basic.flow.json'), 'utf8'));
describe('dashboardAPI edge example structure', () => { test('basic example includes node type dashboardapi', () => {
it('basic example includes node type dashboardapi', () => {
const count = flow.filter((n) => n && n.type === 'dashboardapi').length; const count = flow.filter((n) => n && n.type === 'dashboardapi').length;
expect(count).toBeGreaterThanOrEqual(1); assert.ok(count >= 1, `expected ≥1 dashboardapi node, got ${count}`);
});
}); });

View File

@@ -1,3 +1,5 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
@@ -7,17 +9,15 @@ function loadJson(file) {
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')); return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
} }
describe('dashboardAPI integration examples', () => { test('examples package exists for dashboardAPI', () => {
it('examples package exists for dashboardAPI', () => {
for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) { for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
expect(fs.existsSync(path.join(dir, file))).toBe(true); assert.ok(fs.existsSync(path.join(dir, file)), `missing ${file}`);
} }
}); });
it('example flows are parseable arrays for dashboardAPI', () => { test('example flows are parseable arrays for dashboardAPI', () => {
for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) { for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
const parsed = loadJson(file); const parsed = loadJson(file);
expect(Array.isArray(parsed)).toBe(true); assert.ok(Array.isArray(parsed), `${file} is not an array`);
} }
}); });
});

149
wiki/Home.md Normal file
View File

@@ -0,0 +1,149 @@
# dashboardAPI
![code-ref](https://img.shields.io/badge/code--ref-a6f09d8-blue) ![s88](https://img.shields.io/badge/S88-Utility-dddddd) ![status](https://img.shields.io/badge/status-pending--review-yellow)
A `dashboardAPI` node converts EVOLV node topology into Grafana dashboards. On each inbound `child.register` event it resolves the child source, walks its direct children, loads per-`softwareType` Grafana JSON templates from `config/`, and emits one HTTP upsert request per dashboard on Port 0 to a downstream `http request` node. Sits adjacent to the S88 hierarchy as a passive HTTP emitter &mdash; **no measurements, no tick loop, no parent registration**.
> [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
---
## At a glance
| Thing | Value |
|:---|:---|
| What it represents | Utility bridge between EVOLV topology and Grafana &mdash; auto-generates dashboards from `child.register` events |
| S88 level | **Utility** &mdash; not in the S88 hierarchy; sits adjacent to it |
| Use it when | You want Grafana dashboards to materialise automatically when an EVOLV node graph is deployed |
| Don't use it for | Maintaining hand-curated Grafana dashboards (will overwrite); arbitrary Grafana API calls; tick / measurement data plumbing |
| Children it accepts | Any EVOLV node whose `nodeSource.config` carries `functionality.softwareType` |
| Parents it talks to | None &mdash; dashboardAPI is a passive sink; it does not register with a parent |
---
## How it fits
```mermaid
flowchart LR
ps[pumpingStation<br/>Process Cell]:::pc -.child.register.-> dash
mgc[machineGroupControl<br/>Unit]:::unit -.child.register.-> dash
rm[rotatingMachine<br/>Equipment]:::equip -.child.register.-> dash
meas[measurement<br/>Control Module]:::ctrl -.child.register.-> dash
dash[dashboardAPI<br/>Utility]:::neutral -->|"POST /api/dashboards/db"| http[http request<br/>node-red core]:::neutral
http --> grafana[(Grafana<br/>HTTP API)]
grafana -.renders dashboards for.-> ff[FlowFuse / Browser]
classDef pc fill:#0c99d9,color:#fff
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
classDef neutral fill:#dddddd,color:#000
```
Dashed arrows = inbound `child.register` events from any EVOLV process node. The solid arrow is the outbound HTTP upsert envelope on Port 0 &mdash; emitted **once per generated dashboard** in the walked graph. S88 colours and the utility-neutral `#dddddd` are anchored in `.claude/rules/node-red-flow-layout.md`.
---
## Try it &mdash; 3-minute demo
Import the basic example flow, deploy, and watch a `child.register` payload turn into a Grafana dashboard upsert request.
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/dashboardAPI/examples/basic.flow.json \
http://localhost:1880/flow
```
What to click after deploy:
1. Open the inject node (`basic trigger`) and edit the payload to a `{source: {config: {...}}}` shape &mdash; see [Reference &mdash; Examples](Reference-Examples#wiring-pattern) for the minimal inline-payload shape.
2. Fire the inject. Watch the debug pane: one `topic: 'create'` HTTP envelope appears per dashboard in the walked graph (root + direct children).
3. Wire a downstream `http request` node (method `POST`) to the dashboardAPI output to actually POST the envelope to Grafana.
> [!IMPORTANT]
> **GIF needed.** Demo recording of the inject &rarr; Port-0 envelope &rarr; Grafana dashboard upsert path. Save as `wiki/_partial-gifs/dashboardAPI/01-basic-demo.gif`, target &le; 1&nbsp;MB after `gifsicle -O3 --lossy=80`.
> [!WARNING]
> The shipped `basic.flow.json` / `integration.flow.json` / `edge.flow.json` are stubs &mdash; the inject payloads do not yet conform to the `child.register` resolver's expected shape. They will trigger `Missing or invalid child node` errors until updated. Tracked in [Limitations &mdash; Example flow stubs](Reference-Limitations#example-flow-stubs).
---
## The one thing you'll send
| Topic | Aliases | Payload | What it does |
|:---|:---|:---|:---|
| `child.register` | `registerChild` | `string` (child node id) **or** `{source: {...}}` **or** `{config: {...}}` (optionally `msg.includeChildren: boolean`, default `true`) | Resolves the child source (`RED.nodes.getNode` &rarr; `node._flow.getNode` &rarr; inline payload), calls `source.generateDashboardsForGraph(child, {includeChildren})`, then emits one `topic: 'create'` HTTP-upsert message on Port 0 per generated dashboard. |
That's it. There is no `set.*`, no `cmd.*`, no `query.*` &mdash; the registry has a single canonical topic (alias-with-deprecation). The legacy `registerChild` alias logs a one-time deprecation warning on first use.
---
## What you'll see come out
Sample Port 0 message after a `child.register` for a `pumpingStation` node with two direct children:
```json
{
"topic": "create",
"url": "http://grafana:3000/api/dashboards/db",
"method": "POST",
"headers": {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Bearer eyJ..."
},
"payload": {
"dashboard": { "uid": "a1b2c3d4e5f6", "title": "Pumping Station Demo", "templating": {...} },
"folderId": 0,
"overwrite": true
},
"meta": {
"nodeId": "ps_demo",
"softwareType": "pumpingStation",
"uid": "a1b2c3d4e5f6",
"title": "Pumping Station Demo"
}
}
```
| Field | Meaning |
|:---|:---|
| `topic` | Always `'create'` &mdash; signals a dashboard-upsert HTTP envelope. |
| `url` | `grafanaUpsertUrl()` = `<protocol>://<host>:<port>/api/dashboards/db`. |
| `method` | Always `POST`. |
| `headers.Authorization` | Present only when `bearerToken` is configured; omitted otherwise. |
| `payload.dashboard` | The composed Grafana dashboard JSON (template + templating vars filled in). |
| `payload.dashboard.uid` | `sha1(softwareType:nodeId).slice(0, 12)` &mdash; stable across re-deploys. |
| `meta.*` | Correlation fields for the downstream consumer (nodeId, softwareType, uid, title). |
Inbound `msg` fields propagate via spread (`{...msg, ...envelope}`) so any caller-supplied correlation / trace fields survive.
> Port 1 (InfluxDB telemetry) and Port 2 (registration / control plumbing) are **unused** &mdash; dashboardAPI has no measurements and does not register with a parent. See [Reference &mdash; Architecture](Reference-Architecture#output-ports).
---
## The new bit &mdash; no BaseNodeAdapter / BaseDomain
Most EVOLV nodes extend `BaseNodeAdapter` + `BaseDomain` from `generalFunctions/`. `dashboardAPI` does **not** &mdash; per `OPEN_QUESTIONS.md` (2026-05-10) the decision is to keep a bespoke adapter until `BaseNodeAdapter` grows passive / HTTP-only flags.
Reasons:
- No `generalFunctions/src/configs/dashboardapi.json` &mdash; `BaseDomain`'s constructor unconditionally calls `configManager.getConfig(ctor.name)` and would throw. The local `dependencies/dashboardapi/dashboardapiConfig.json` is for the editor menu endpoint, not the runtime config pipeline.
- No periodic output &mdash; `BaseNodeAdapter._emitOutputs()` / `outputUtils.formatMsg` assumes a delta-compressed Port 0 / 1 stream; dashboardAPI emits HTTP-shaped messages instead.
- No registration to a parent &mdash; `BaseNodeAdapter._scheduleRegistration` would emit a spurious `child.register` of its own.
- No status badge / tick / measurements / children of its own.
dashboardAPI uses the shared `commandRegistry` (canonical-topic naming + alias-with-deprecation) and stops there. See [Reference &mdash; Architecture](Reference-Architecture#why-no-basenodeadapter--basedomain) for the full rationale.
---
## Need more?
| Page | What you'll find |
|:---|:---|
| [Reference &mdash; Contracts](Reference-Contracts) | Full topic contract, config schema, child resolution rules, template alias table |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, HTTP-endpoint lifecycle, template loader, UID stability, graph walk |
| [Reference &mdash; Examples](Reference-Examples) | Shipped example flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Legacy filename drift, stub flows, missing template handling, open questions |
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) &middot; [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) &middot; [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)

View File

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

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

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

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

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

View File

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

20
wiki/_Sidebar.md Normal file
View File

@@ -0,0 +1,20 @@
### dashboardAPI
- [Home](Home)
**Reference**
- [Contracts](Reference-Contracts)
- [Architecture](Reference-Architecture)
- [Examples](Reference-Examples)
- [Limitations](Reference-Limitations)
**Related**
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home)
- [pumpingStation wiki](https://gitea.wbd-rd.nl/RnD/pumpingStation/wiki/Home)
- [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki/Home)
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)

View File

@@ -0,0 +1,18 @@
No domain output. dashboardAPI emits **HTTP request envelopes on Port 0**, shaped for a downstream `http request` node:
```js
{
topic: 'create',
url: 'http://<grafana>:<port>/api/dashboards/db',
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: 'Bearer …' // only when bearerToken is set
},
payload: { dashboard: {…}, folderId: 0, overwrite: true },
meta: { nodeId, softwareType, uid, title }
}
```
Port 1 (InfluxDB telemetry) and Port 2 (registration / control plumbing) are unused — dashboardAPI has no measurements and does not register with a parent.