Adds per-panel `meta.emittedFields` to machine.json (rotatingMachine) and machineGroup.json (MGC) templates. Each non-row panel declares the Influx field paths it visualizes, so a parent template's composer can filter out panels already covered by its children (#39 no-data-duplication rule). - config/machine.json: 13 non-row panels annotated. - config/machineGroup.json: panels annotated. - src/specificClass.js: collectEmittedFields(dashboard) helper. - test/basic/slice37-emitted-fields.basic.test.js: 4 cases (template loads with annotations, aggregation, missing-meta graceful, null input). PRD F-6 panel set audit: machine.json already covers all the PRD-required panels (State/Mode/Ctrl%/Runtime/NCog%/Flow/Efficiency/Pressure/Temperature/ Diagnostics) — substantially more than asked. No new panels added. PRD F-7 predicted-vs-measured side-by-side: deferred. Current architecture is "1 dashboard per node" (each child gets its own dashboard, cross-linked from the parent), not "1 dashboard with N composed panels." Side-by-side rendering of predicted (rotatingMachine dashboard) + measured (measurement child dashboard) lives naturally as drill-down navigation today. Refactor to a single-dashboard composition model would be substantial — flagged in the issue comment for v2 if the drill-down UX proves insufficient. Closes #37
258 lines
8.7 KiB
JavaScript
258 lines
8.7 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);
|
|
}
|
|
|
|
// 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;
|