Files
dashboardAPI/test/_output-manifest.md
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

7.8 KiB
Raw Blame History

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.emitDashboardsForsource.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.