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:
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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 `<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);
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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