- 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>
7.5 KiB
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 |
source.buildUpsertRequest() |
string when configured; absent when empty | populated, absent (degraded — empty config) | 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.emitDashboardsForalways buildspayload: { dashboard, overwrite, ... }. Verified. - ❌ Mixing absent vs null for optional fields —
folderUid/folderIdare absent when unconfigured, nevernull. Verified. - ❌ Per-call token stamping — token is set on
headers.Authorizationwhen 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.