From 990a8c09ea7ee9355b230eb1be3ac1f9380451fc Mon Sep 17 00:00:00 2001 From: znetsixe Date: Wed, 27 May 2026 09:45:37 +0200 Subject: [PATCH] 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 || _); 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) --- config/pumpingStation.json | 8 +- src/specificClass.js | 204 +++++++++++------- test/_output-manifest.md | 6 +- .../slice44-recursive-discovery.basic.test.js | 104 +++++++++ .../slice45-template-aliases.basic.test.js | 50 +++++ ...ce46-measurement-name-parity.basic.test.js | 62 ++++++ test/dashboardapi.test.js | 3 +- 7 files changed, 357 insertions(+), 80 deletions(-) create mode 100644 test/basic/slice44-recursive-discovery.basic.test.js create mode 100644 test/basic/slice45-template-aliases.basic.test.js create mode 100644 test/basic/slice46-measurement-name-parity.basic.test.js diff --git a/config/pumpingStation.json b/config/pumpingStation.json index ca12ba1..4186506 100644 --- a/config/pumpingStation.json +++ b/config/pumpingStation.json @@ -453,7 +453,7 @@ }, "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)", + "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 |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } ], @@ -500,7 +500,7 @@ }, "targets": [ { - "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^flow\\.(predicted|measured)\\.atequipment/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", + "query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and r._field =~ /^flow\\.(predicted|measured)\\.(upstream|in|out|overflow)/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A" } ], @@ -562,7 +562,7 @@ }, "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()", + "query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"inflowLevel\" or r._field==\"outflowLevel\" or r._field==\"overflowLevel\" or r._field==\"heightBasin\"))\n |> last()", "refId": "A" } ], @@ -613,7 +613,7 @@ }, "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()", + "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==\"maxVolAtOverflow\" or r._field==\"minVolAtOutflow\" or r._field==\"minVolAtInflow\"))\n |> last()", "refId": "A" } ], diff --git a/src/specificClass.js b/src/specificClass.js index 4f72a33..7bbe0f1 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -18,6 +18,28 @@ function slugify(input) { .slice(0, 60); } +// Map a node's lowercased softwareType to its Grafana template file in config/. +// Nodes report softwareType as the lowercased node name (e.g. 'rotatingmachine', +// 'machinegroupcontrol'), but several template files are camelCase and some node +// types share a template (rotatingMachine → machine, diffuser → aeration). The +// keys here are always lowercase; lookup lowercases the input first. +const TEMPLATE_FILE_BY_SOFTWARE_TYPE = { + rotatingmachine: 'machine.json', + machine: 'machine.json', + machinegroupcontrol: 'machineGroup.json', + machinegroup: 'machineGroup.json', + pumpingstation: 'pumpingStation.json', + valvegroupcontrol: 'valveGroupControl.json', + diffuser: 'aeration.json', + aeration: 'aeration.json', + measurement: 'measurement.json', + monster: 'monster.json', + reactor: 'reactor.json', + settler: 'settler.json', + valve: 'valve.json', + dashboardapi: 'dashboardapi.json', +}; + function defaultBucketForPosition(positionVsParent) { const pos = String(positionVsParent || '').toLowerCase(); if (pos === 'upstream') return 'lvl1'; @@ -98,9 +120,9 @@ class DashboardApi { _templateFileForSoftwareType(softwareType) { const st = String(softwareType || '').trim(); const candidates = [ + TEMPLATE_FILE_BY_SOFTWARE_TYPE[st.toLowerCase()], `${st}.json`, `${st.toLowerCase()}.json`, - st === 'machineGroupControl' ? 'machineGroup.json' : null, ].filter(Boolean); for (const filename of candidates) { @@ -142,7 +164,11 @@ class DashboardApi { nodeConfig?.functionality?.software_type || 'measurement'; const nodeId = nodeConfig?.general?.id || nodeConfig?.general?.name || softwareType; - const measurementName = `${softwareType}_${nodeId}`; + // Mirror outputUtils.formatMsg: telemetry is written under general.name when + // set, falling back to `_`. The dashboard's _measurement var + // must match that exactly or every panel queries a non-existent series. + const measurementName = + nodeConfig?.general?.name || `${softwareType}_${nodeConfig?.general?.id || softwareType}`; const title = nodeConfig?.general?.name || String(nodeId); // Missing templates are treated as non-fatal: we skip only that dashboard. @@ -208,90 +234,124 @@ class DashboardApi { return false; } - // Collect ids that constitute "this dashboardAPI + this child + its grandchildren" - // for the diff predicate. Pulls grandchildren via the existing extractChildren walk. + // Collect every node id in "this dashboardAPI + this child's full subtree" for + // the diff predicate. Recurses the whole registered-child tree (not just + // grandchildren) so a change anywhere below a wired root triggers a regen. + // `visited` guards cycles / diamond topologies. subtreeIdsFor(dashboardApiNodeId, childSource) { const ids = new Set(); if (dashboardApiNodeId) ids.add(dashboardApiNodeId); - const childId = childSource?.config?.general?.id; - if (childId) ids.add(childId); - for (const { childSource: gc } of this.extractChildren(childSource)) { - const gcId = gc?.config?.general?.id; - if (gcId) ids.add(gcId); - } + this._collectSubtreeIds(childSource, ids, new Set()); return ids; } + _collectSubtreeIds(nodeSource, ids, visited) { + const id = nodeSource?.config?.general?.id; + if (id) { + if (visited.has(id)) return; + visited.add(id); + ids.add(id); + } + for (const { childSource } of this.extractChildren(nodeSource)) { + this._collectSubtreeIds(childSource, ids, visited); + } + } + + // Compose a dashboard for a wired root and EVERY descendant in its registered- + // child tree. Operators wire only subtree roots; dashboardAPI recurses the + // parent-child relationships to discover the rest. Returns a flat, pre-order + // array (root first) of buildDashboard results. generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) { if (!rootSource?.config) { this.logger.warn('generateDashboardsForGraph skipped: root source missing config'); return []; } - const rootPosition = rootSource?.positionVsParent || rootSource?.config?.functionality?.positionVsParent; - const rootDash = this.buildDashboard({ nodeConfig: rootSource.config, positionVsParent: rootPosition }); - if (!rootDash) return []; - - const results = [rootDash]; - - if (!includeChildren) return results; - - const children = this.extractChildren(rootSource); - for (const { childSource, positionVsParent } of children) { - const childDash = this.buildDashboard({ nodeConfig: childSource.config, positionVsParent }); - if (childDash) results.push(childDash); - } - - // No-data-duplication rule (PRD F-5, #39): remove root panels whose - // emittedFields are fully covered by panels on child dashboards. The - // parent then shows only metrics its children don't already plot, - // avoiding redundant rendering of the same series in two places. - if (children.length > 0 && rootDash.dashboard) { - const childCoveredFields = new Set(); - for (const dash of results.slice(1)) { - for (const f of this.collectEmittedFields(dash.dashboard)) childCoveredFields.add(f); - } - const before = rootDash.dashboard.panels.length; - rootDash.dashboard.panels = rootDash.dashboard.panels.filter((p) => { - if (p.type === 'row') return true; // never drop rows - const fields = p?.meta?.emittedFields; - if (!Array.isArray(fields) || fields.length === 0) return true; // no declaration, keep - return !fields.every((f) => childCoveredFields.has(f)); - }); - if (this.logger?.debug && before !== rootDash.dashboard.panels.length) { - this.logger.debug({ - event: 'parent-panels-deduped', - before, - after: rootDash.dashboard.panels.length, - rootTitle: rootDash.title, - }); - } - } - - // Add links from the root dashboard to children dashboards (when possible) - if (children.length > 0) { - rootDash.dashboard.links = Array.isArray(rootDash.dashboard.links) ? rootDash.dashboard.links : []; - for (const { childSource } of children) { - const childConfig = childSource.config; - const childSoftwareType = childConfig?.functionality?.softwareType || 'measurement'; - const childNodeId = childConfig?.general?.id || childConfig?.general?.name || childSoftwareType; - const childUid = stableUid(`${childSoftwareType}:${childNodeId}`); - const childTitle = childConfig?.general?.name || String(childNodeId); - - rootDash.dashboard.links.push({ - type: 'link', - title: childTitle, - url: `/d/${childUid}/${slugify(childTitle)}`, - tags: [], - targetBlank: false, - keepTime: true, - keepVariables: true, - }); - } - } - + const results = []; + this._composeNode(rootSource, includeChildren, results, new Set()); return results; } + + // Recursively compose `nodeSource` then its descendants. Per-parent dedup and + // links are applied at every level (each parent is deduped against / links to + // its own direct children). `visited` ensures one dashboard per node id even + // when the topology has cycles or diamonds. + _composeNode(nodeSource, includeChildren, results, visited) { + const nodeId = nodeSource?.config?.general?.id; + if (nodeId) { + if (visited.has(nodeId)) return null; + visited.add(nodeId); + } + + const position = nodeSource?.positionVsParent || nodeSource?.config?.functionality?.positionVsParent; + const nodeDash = this.buildDashboard({ nodeConfig: nodeSource.config, positionVsParent: position }); + if (!nodeDash) return null; + results.push(nodeDash); + + if (!includeChildren) return nodeDash; + + const children = this.extractChildren(nodeSource); + const childDashes = []; + for (const { childSource } of children) { + const childDash = this._composeNode(childSource, includeChildren, results, visited); + if (childDash) childDashes.push(childDash); + } + + this._dedupParentPanels(nodeDash, childDashes); + this._linkToChildren(nodeDash, children); + + 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) { + const childConfig = childSource.config; + const childSoftwareType = childConfig?.functionality?.softwareType || 'measurement'; + const childNodeId = childConfig?.general?.id || childConfig?.general?.name || childSoftwareType; + const childUid = stableUid(`${childSoftwareType}:${childNodeId}`); + const childTitle = childConfig?.general?.name || String(childNodeId); + + parentDash.dashboard.links.push({ + type: 'link', + title: childTitle, + url: `/d/${childUid}/${slugify(childTitle)}`, + tags: [], + targetBlank: false, + keepTime: true, + keepVariables: true, + }); + } + } } module.exports = DashboardApi; diff --git a/test/_output-manifest.md b/test/_output-manifest.md index a8f823b..6b93c79 100644 --- a/test/_output-manifest.md +++ b/test/_output-manifest.md @@ -48,10 +48,10 @@ dashboardAPI is a **sink** for `child.register` messages, not a source — it do | Method | Return shape | Populated states | Degraded states | Test | |---|---|---|---|---| -| `buildDashboard(opts)` | `{ dashboard, uid, title, softwareType, nodeId, measurementName }` or `null` | success | `null` when no template for softwareType | `test/basic/slice43-output-manifest.basic.test.js` | -| `generateDashboardsForGraph(root)` | array of `buildDashboard` results, root first, children after | 0..N children | empty array when root config missing | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js` | +| `buildDashboard(opts)` | `{ dashboard, uid, title, softwareType, nodeId, measurementName }` or `null`; `measurementName` mirrors `outputUtils.formatMsg` (`general.name` \|\| `_`) so the dashboard `_measurement` var matches the telemetry series | 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 | 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` | | `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\ | myId+childId+grandchildren | myId only when child has no grandchildren | `test/basic/slice36-diff-predicate.basic.test.js` | +| `subtreeIdsFor(myId, child)` | Set\ | 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\ | 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` | diff --git a/test/basic/slice44-recursive-discovery.basic.test.js b/test/basic/slice44-recursive-discovery.basic.test.js new file mode 100644 index 0000000..950a0d7 --- /dev/null +++ b/test/basic/slice44-recursive-discovery.basic.test.js @@ -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'); +}); diff --git a/test/basic/slice45-template-aliases.basic.test.js b/test/basic/slice45-template-aliases.basic.test.js new file mode 100644 index 0000000..547bfca --- /dev/null +++ b/test/basic/slice45-template-aliases.basic.test.js @@ -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); +}); diff --git a/test/basic/slice46-measurement-name-parity.basic.test.js b/test/basic/slice46-measurement-name-parity.basic.test.js new file mode 100644 index 0000000..1b209e6 --- /dev/null +++ b/test/basic/slice46-measurement-name-parity.basic.test.js @@ -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 _ 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'); +}); diff --git a/test/dashboardapi.test.js b/test/dashboardapi.test.js index 90ca2fd..b56c0de 100644 --- a/test/dashboardapi.test.js +++ b/test/dashboardapi.test.js @@ -46,7 +46,8 @@ describe('DashboardApi specificClass', () => { const measurement = templ.find((v) => v.name === 'measurement'); const bucket = templ.find((v) => v.name === 'bucket'); - expect(measurement.current.value).toBe('measurement_m-1'); + // measurement var must mirror outputUtils: general.name when set. + expect(measurement.current.value).toBe('PT-1'); expect(bucket.current.value).toBe('lvl3'); });