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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user