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>
This commit is contained in:
@@ -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` \|\| `<softwareType>_<id>`) 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\<string\> | myId+childId+grandchildren | myId only when child has no grandchildren | `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` |
|
||||
|
||||
|
||||
104
test/basic/slice44-recursive-discovery.basic.test.js
Normal file
104
test/basic/slice44-recursive-discovery.basic.test.js
Normal 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');
|
||||
});
|
||||
50
test/basic/slice45-template-aliases.basic.test.js
Normal file
50
test/basic/slice45-template-aliases.basic.test.js
Normal 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);
|
||||
});
|
||||
62
test/basic/slice46-measurement-name-parity.basic.test.js
Normal file
62
test/basic/slice46-measurement-name-parity.basic.test.js
Normal 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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user