Files
dashboardAPI/src/specificClass.js
znetsixe a76f22281e feat(dashboardapi): no-data-duplication rule for parent dashboards (#39)
When generateDashboardsForGraph builds a root dashboard for a parent (e.g.
pumpingStation) and a set of child dashboards (e.g. measurements), it now
removes any non-row panel from the root whose meta.emittedFields are fully
covered by panels declared in any child dashboard. Result: the parent
shows only metrics its children don't already plot, eliminating redundant
rendering of the same series in two dashboards.

- config/pumpingStation.json: 11 non-row panels annotated with
  meta.emittedFields (Direction, Time Left, Flow Source, Fill %, Level (x2),
  Volume, Net Flow Rate, Inflow+Outflow, Heights, Volume Limits).
- src/specificClass.js: generateDashboardsForGraph runs the parent-panel
  filter after composing children; row panels always kept; panels without
  emittedFields declaration always kept (no silent removal).
- test/basic/slice39-no-duplication.basic.test.js: 4 cases — annotation
  presence, child-covered removal, no-overlap preservation, row preservation.

Closes #39
2026-05-26 18:01:58 +02:00

284 lines
9.9 KiB
JavaScript

const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { logger } = require('generalFunctions');
function stableUid(input) {
const digest = crypto.createHash('sha1').update(String(input)).digest('hex');
return digest.slice(0, 12);
}
function slugify(input) {
return String(input || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 60);
}
function defaultBucketForPosition(positionVsParent) {
const pos = String(positionVsParent || '').toLowerCase();
if (pos === 'upstream') return 'lvl1';
if (pos === 'downstream') return 'lvl3';
return 'lvl2';
}
function updateTemplatingVar(dashboard, varName, value) {
const list = dashboard?.templating?.list;
if (!Array.isArray(list)) return;
const variable = list.find((v) => v && v.name === varName);
if (!variable) return;
variable.current = variable.current || {};
variable.current.text = value;
variable.current.value = value;
if (Array.isArray(variable.options) && variable.options.length > 0) {
variable.options[0] = variable.options[0] || {};
variable.options[0].text = value;
variable.options[0].value = value;
}
variable.query = value;
}
/**
* Dashboard domain service.
* Builds Grafana dashboard payloads from EVOLV node config and child topology.
*/
class DashboardApi {
constructor(config = {}) {
this.config = {
general: {
name: config?.general?.name || 'dashboardapi',
logging: {
enabled: config?.general?.logging?.enabled ?? true,
logLevel: config?.general?.logging?.logLevel || 'info',
},
},
grafanaConnector: {
protocol: config?.grafanaConnector?.protocol || 'http',
host: config?.grafanaConnector?.host || 'localhost',
port: Number(config?.grafanaConnector?.port || 3000),
bearerToken: config?.grafanaConnector?.bearerToken || '',
folderUid: config?.grafanaConnector?.folderUid || '',
},
defaultBucket: config?.defaultBucket || '',
bucketMap: config?.bucketMap || {},
};
this.logger = new logger(
this.config.general.logging.enabled,
this.config.general.logging.logLevel,
this.config.general.name
);
}
_templatesDir() {
return path.join(__dirname, '..', 'config');
}
_templateFileForSoftwareType(softwareType) {
const st = String(softwareType || '').trim();
const candidates = [
`${st}.json`,
`${st.toLowerCase()}.json`,
st === 'machineGroupControl' ? 'machineGroup.json' : null,
].filter(Boolean);
for (const filename of candidates) {
const fullPath = path.join(this._templatesDir(), filename);
if (fs.existsSync(fullPath)) return fullPath;
}
this.logger.warn(`No dashboard template found for softwareType=${st}`);
return null;
}
loadTemplate(softwareType) {
const templatePath = this._templateFileForSoftwareType(softwareType);
if (!templatePath) return null;
const raw = fs.readFileSync(templatePath, 'utf8');
return JSON.parse(raw);
}
// Collect every `meta.emittedFields` declared by panels in a template.
// Used by #39's parent panel filter — a parent panel whose emittedFields
// are fully covered by its children's panels is removed.
collectEmittedFields(dashboard) {
const out = new Set();
for (const panel of dashboard?.panels || []) {
const fields = panel?.meta?.emittedFields;
if (Array.isArray(fields)) for (const f of fields) out.add(f);
}
return out;
}
grafanaUpsertUrl() {
const { protocol, host, port } = this.config.grafanaConnector;
return `${protocol}://${host}:${port}/api/dashboards/db`;
}
buildDashboard({ nodeConfig, positionVsParent }) {
const softwareType =
nodeConfig?.functionality?.softwareType ||
nodeConfig?.functionality?.software_type ||
'measurement';
const nodeId = nodeConfig?.general?.id || nodeConfig?.general?.name || softwareType;
const measurementName = `${softwareType}_${nodeId}`;
const title = nodeConfig?.general?.name || String(nodeId);
// Missing templates are treated as non-fatal: we skip only that dashboard.
const dashboard = this.loadTemplate(softwareType);
if (!dashboard) {
this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`);
return null;
}
const uid = stableUid(`${softwareType}:${nodeId}`);
dashboard.id = null;
dashboard.uid = uid;
dashboard.title = title;
dashboard.tags = Array.from(
new Set([...(dashboard.tags || []), 'EVOLV', softwareType, String(positionVsParent || '')].filter(Boolean))
);
const bucket =
this.config.defaultBucket ||
this.config.bucketMap[String(positionVsParent)] ||
defaultBucketForPosition(positionVsParent);
updateTemplatingVar(dashboard, 'measurement', measurementName);
updateTemplatingVar(dashboard, 'bucket', bucket);
return { dashboard, uid, title, softwareType, nodeId, measurementName };
}
buildUpsertRequest({ dashboard, folderId, folderUid, overwrite = true }) {
const out = { dashboard, overwrite };
// Prefer folderUid (modern Grafana API). Fall back to folderId for older callers.
const uid = folderUid ?? this.config?.grafanaConnector?.folderUid ?? '';
if (uid) out.folderUid = uid;
else if (typeof folderId === 'number') out.folderId = folderId;
return out;
}
extractChildren(nodeSource) {
const out = [];
const reg = nodeSource?.childRegistrationUtils?.registeredChildren;
if (reg && typeof reg.values === 'function') {
for (const entry of reg.values()) {
const child = entry?.child;
if (!child?.config) continue;
out.push({ childSource: child, positionVsParent: entry?.position || child.positionVsParent });
}
return out;
}
return out;
}
// Predicate from Gitea issue #32 spike (S1 findings). Given the diff payload
// from Node-RED's flows:started event and a set of node ids that constitute
// "my subtree", decides whether the subtree changed on this deploy.
// `null` diff (first deploy / startup) → always regen (safe default).
subtreeChanged(diff, subtreeIds) {
if (!diff) return true;
const mine = new Set(subtreeIds);
for (const field of ['added', 'changed', 'removed', 'rewired']) {
const arr = diff[field] || [];
if (arr.some((id) => mine.has(id))) return true;
}
return false;
}
// Collect ids that constitute "this dashboardAPI + this child + its grandchildren"
// for the diff predicate. Pulls grandchildren via the existing extractChildren walk.
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);
}
return ids;
}
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,
});
}
}
return results;
}
}
module.exports = DashboardApi;