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:
znetsixe
2026-05-27 09:45:37 +02:00
parent dc08c85409
commit 990a8c09ea
7 changed files with 357 additions and 80 deletions

View File

@@ -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;