2026-01-13 14:29:43 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 09:45:37 +02:00
|
|
|
// 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',
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-13 14:29:43 +01:00
|
|
|
function defaultBucketForPosition(positionVsParent) {
|
|
|
|
|
const pos = String(positionVsParent || '').toLowerCase();
|
|
|
|
|
if (pos === 'upstream') return 'lvl1';
|
|
|
|
|
if (pos === 'downstream') return 'lvl3';
|
|
|
|
|
return 'lvl2';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 10:32:52 +02:00
|
|
|
// Replace `{{name}}` placeholders in a raw JSON template string with values
|
|
|
|
|
// from `vars`. Unknown placeholders are left intact. Used to inject node-config
|
|
|
|
|
// derived constants (basin geometry, threshold y-positions) into a template
|
|
|
|
|
// before JSON.parse — so the resulting dashboard has concrete numbers in
|
|
|
|
|
// fieldConfig.thresholds and canvas element placements. Mustache-style braces
|
|
|
|
|
// keep these placeholders distinct from Grafana's own `${var}` dashboard
|
|
|
|
|
// variables (which are interpreted by Grafana at render time).
|
|
|
|
|
function substituteTemplateVars(rawJson, vars) {
|
|
|
|
|
if (!vars || !Object.keys(vars).length) return rawJson;
|
|
|
|
|
return rawJson.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (m, key) => (
|
|
|
|
|
Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : m
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 14:29:43 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 13:16:58 +01:00
|
|
|
/**
|
|
|
|
|
* Dashboard domain service.
|
|
|
|
|
* Builds Grafana dashboard payloads from EVOLV node config and child topology.
|
|
|
|
|
*/
|
2026-01-13 14:29:43 +01:00
|
|
|
class DashboardApi {
|
|
|
|
|
constructor(config = {}) {
|
|
|
|
|
this.config = {
|
|
|
|
|
general: {
|
|
|
|
|
name: config?.general?.name || 'dashboardapi',
|
|
|
|
|
logging: {
|
2026-05-19 15:59:19 +02:00
|
|
|
enabled: config?.general?.logging?.enabled ?? true,
|
2026-01-13 14:29:43 +01:00
|
|
|
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 || '',
|
2026-05-27 21:02:38 +02:00
|
|
|
// folderTitle is the durable way to target a folder: Grafana folder
|
|
|
|
|
// uids change whenever the instance is rebuilt, so a pinned folderUid
|
|
|
|
|
// goes stale (every upsert then 400s "folder not found"). When set, the
|
|
|
|
|
// uid is resolved (and the folder created if absent) by name at emit
|
|
|
|
|
// time. folderUid stays supported as an explicit override / fallback.
|
|
|
|
|
folderTitle: config?.grafanaConnector?.folderTitle || '',
|
2026-05-26 17:53:42 +02:00
|
|
|
folderUid: config?.grafanaConnector?.folderUid || '',
|
2026-01-13 14:29:43 +01:00
|
|
|
},
|
2026-03-11 11:13:44 +01:00
|
|
|
defaultBucket: config?.defaultBucket || '',
|
2026-01-13 14:29:43 +01:00
|
|
|
bucketMap: config?.bucketMap || {},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.logger = new logger(
|
|
|
|
|
this.config.general.logging.enabled,
|
|
|
|
|
this.config.general.logging.logLevel,
|
|
|
|
|
this.config.general.name
|
|
|
|
|
);
|
2026-05-26 18:05:31 +02:00
|
|
|
|
|
|
|
|
// Light state cache for manual regen (#41). Stores the latest child
|
|
|
|
|
// source object per child id so `regenerate-dashboard` can re-emit
|
|
|
|
|
// dashboards without waiting for children to re-register.
|
|
|
|
|
this._lastChildSources = new Map();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
recordChild(childSource) {
|
|
|
|
|
const id = childSource?.config?.general?.id;
|
|
|
|
|
if (id) this._lastChildSources.set(id, childSource);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cachedChildSources() {
|
|
|
|
|
return Array.from(this._lastChildSources.values());
|
2026-01-13 14:29:43 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_templatesDir() {
|
|
|
|
|
return path.join(__dirname, '..', 'config');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_templateFileForSoftwareType(softwareType) {
|
|
|
|
|
const st = String(softwareType || '').trim();
|
|
|
|
|
const candidates = [
|
2026-05-27 09:45:37 +02:00
|
|
|
TEMPLATE_FILE_BY_SOFTWARE_TYPE[st.toLowerCase()],
|
2026-01-13 14:29:43 +01:00
|
|
|
`${st}.json`,
|
|
|
|
|
`${st.toLowerCase()}.json`,
|
|
|
|
|
].filter(Boolean);
|
|
|
|
|
|
|
|
|
|
for (const filename of candidates) {
|
|
|
|
|
const fullPath = path.join(this._templatesDir(), filename);
|
|
|
|
|
if (fs.existsSync(fullPath)) return fullPath;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 13:16:58 +01:00
|
|
|
this.logger.warn(`No dashboard template found for softwareType=${st}`);
|
|
|
|
|
return null;
|
2026-01-13 14:29:43 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-28 10:32:52 +02:00
|
|
|
loadTemplate(softwareType, templateVars = null) {
|
2026-01-13 14:29:43 +01:00
|
|
|
const templatePath = this._templateFileForSoftwareType(softwareType);
|
2026-02-23 13:16:58 +01:00
|
|
|
if (!templatePath) return null;
|
2026-05-28 10:32:52 +02:00
|
|
|
let raw = fs.readFileSync(templatePath, 'utf8');
|
|
|
|
|
// Always substitute — falls back to per-softwareType defaults so the
|
|
|
|
|
// template is JSON-parseable even when no nodeConfig is provided (tests,
|
|
|
|
|
// smoke-loading, etc.). _templateVarsForNode returns {} for types that
|
|
|
|
|
// don't use placeholders, which is a no-op pass.
|
|
|
|
|
const vars = templateVars || this._templateVarsForNode(softwareType, null);
|
|
|
|
|
raw = substituteTemplateVars(raw, vars);
|
2026-01-13 14:29:43 +01:00
|
|
|
return JSON.parse(raw);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 10:32:52 +02:00
|
|
|
// Per-softwareType numeric vars baked into the template before JSON.parse.
|
|
|
|
|
// Today only pumpingStation needs this (basin geometry → bar-gauge thresholds
|
|
|
|
|
// and canvas y-positions). Other types return {} and skip substitution.
|
|
|
|
|
_templateVarsForNode(softwareType, nodeConfig) {
|
|
|
|
|
const st = String(softwareType || '').toLowerCase();
|
|
|
|
|
if (st !== 'pumpingstation') return {};
|
|
|
|
|
|
|
|
|
|
// configManager.buildConfig nests basin geometry under `basin.*` and
|
|
|
|
|
// safety percentages under `safety.*` (see generalFunctions/configManager).
|
|
|
|
|
const basin = nodeConfig?.basin || {};
|
|
|
|
|
const safety = nodeConfig?.safety || {};
|
|
|
|
|
const heightBasin = Number(basin.height) || 4;
|
|
|
|
|
const inflowLevel = Number(basin.inflowLevel) || 0;
|
|
|
|
|
const outflowLevel = Number(basin.outflowLevel) || 0;
|
|
|
|
|
const overflowLevel = Number(basin.overflowLevel) || heightBasin;
|
|
|
|
|
const dryRunPct = Number(safety.dryRunThresholdPercent) || 30;
|
|
|
|
|
const highPct = Number(safety.highVolumeSafetyThresholdPercent) || 90;
|
|
|
|
|
|
|
|
|
|
// Mirror specificClass._computeSafetyPoints derivation (pumpingStation).
|
|
|
|
|
const dryRunLevel = outflowLevel * (1 + dryRunPct / 100);
|
|
|
|
|
const highSafetyLevel = overflowLevel * (highPct / 100);
|
|
|
|
|
|
2026-05-28 10:45:51 +02:00
|
|
|
// Canvas tank: rim at y=20px, floor at y=540px (520px tall). Must match
|
|
|
|
|
// hard-coded tank rectangle placement in config/pumpingStation.json
|
|
|
|
|
// (basin row is h:20 grid rows; canvas root frame is 480x600 px).
|
|
|
|
|
const TANK_TOP = 20, TANK_BOT = 540, TANK_H = TANK_BOT - TANK_TOP;
|
2026-05-28 10:32:52 +02:00
|
|
|
const yFor = (v) => +(TANK_BOT - (v / heightBasin) * TANK_H).toFixed(2);
|
|
|
|
|
const tyFor = (yLine) => +(yLine - 8).toFixed(2); // centre 16px text on the line
|
|
|
|
|
|
|
|
|
|
const y_overflow = yFor(overflowLevel);
|
|
|
|
|
const y_highSafety = yFor(highSafetyLevel);
|
|
|
|
|
const y_inflow = yFor(inflowLevel);
|
|
|
|
|
const y_dryRun = yFor(dryRunLevel);
|
|
|
|
|
const y_outflow = yFor(outflowLevel);
|
|
|
|
|
|
|
|
|
|
// Label y-positions get min-gap enforcement so labels never overlap even
|
|
|
|
|
// when thresholds sit nearly on top of each other (e.g. dryRun=2 % means
|
|
|
|
|
// dryRunLevel sits right on outflowLevel; highSafety=98 % puts it under
|
|
|
|
|
// overflow). Lines stay at proportional y; only the label text moves.
|
|
|
|
|
// Two-pass (down + up) mirrors editor's basin-diagram.js placement logic.
|
|
|
|
|
const GAP = 20;
|
|
|
|
|
const labels = [
|
|
|
|
|
{ id: 'overflow', y: tyFor(y_overflow) },
|
|
|
|
|
{ id: 'highSafety', y: tyFor(y_highSafety) },
|
|
|
|
|
{ id: 'inflow', y: tyFor(y_inflow) },
|
|
|
|
|
{ id: 'dryRun', y: tyFor(y_dryRun) },
|
|
|
|
|
{ id: 'outflow', y: tyFor(y_outflow) },
|
|
|
|
|
].sort((a, b) => a.y - b.y);
|
|
|
|
|
for (let i = 1; i < labels.length; i++) {
|
|
|
|
|
if (labels[i].y < labels[i - 1].y + GAP) labels[i].y = labels[i - 1].y + GAP;
|
|
|
|
|
}
|
|
|
|
|
for (let i = labels.length - 2; i >= 0; i--) {
|
|
|
|
|
if (labels[i].y > labels[i + 1].y - GAP) labels[i].y = labels[i + 1].y - GAP;
|
|
|
|
|
}
|
|
|
|
|
const ty = Object.fromEntries(labels.map((l) => [l.id, +l.y.toFixed(2)]));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
heightBasin: +heightBasin.toFixed(2),
|
|
|
|
|
outflowLevel: +outflowLevel.toFixed(3),
|
|
|
|
|
inflowLevel: +inflowLevel.toFixed(3),
|
|
|
|
|
overflowLevel: +overflowLevel.toFixed(3),
|
|
|
|
|
dryRunLevel: +dryRunLevel.toFixed(3),
|
|
|
|
|
highSafetyLevel: +highSafetyLevel.toFixed(3),
|
|
|
|
|
y_overflow, y_highSafety, y_inflow, y_dryRun, y_outflow,
|
|
|
|
|
h_spill: +(y_overflow - TANK_TOP).toFixed(2),
|
|
|
|
|
h_highSafety: +(y_highSafety - y_overflow).toFixed(2),
|
|
|
|
|
h_operating: +(y_outflow - y_highSafety).toFixed(2),
|
|
|
|
|
h_dead: +(TANK_BOT - y_outflow).toFixed(2),
|
|
|
|
|
ty_overflow: ty.overflow,
|
|
|
|
|
ty_highSafety: ty.highSafety,
|
|
|
|
|
ty_inflow: ty.inflow,
|
|
|
|
|
ty_dryRun: ty.dryRun,
|
|
|
|
|
ty_outflow: ty.outflow,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 17:59:37 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 14:29:43 +01:00
|
|
|
grafanaUpsertUrl() {
|
|
|
|
|
const { protocol, host, port } = this.config.grafanaConnector;
|
|
|
|
|
return `${protocol}://${host}:${port}/api/dashboards/db`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 21:02:38 +02:00
|
|
|
grafanaFoldersUrl() {
|
|
|
|
|
const { protocol, host, port } = this.config.grafanaConnector;
|
|
|
|
|
return `${protocol}://${host}:${port}/api/folders`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_grafanaJsonHeaders() {
|
|
|
|
|
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
|
|
|
|
|
const token = this.config.grafanaConnector.bearerToken;
|
|
|
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
|
|
|
return headers;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve the target Grafana folder uid by NAME, creating the folder if it
|
|
|
|
|
// doesn't exist. This is the durable alternative to a pinned folderUid, which
|
|
|
|
|
// goes stale on every Grafana rebuild (the new instance hands the same-named
|
|
|
|
|
// folder a fresh uid, and every dashboard upsert then 400s "folder not
|
|
|
|
|
// found"). Resolution is done once per process and cached.
|
|
|
|
|
//
|
|
|
|
|
// Degradation contract: any failure (no fetch, network error, non-OK
|
|
|
|
|
// response) logs a warning and falls back to the configured folderUid, so the
|
|
|
|
|
// node is never worse off than the pinned-uid behavior it replaces.
|
|
|
|
|
async resolveFolderUid({ fetchImpl = globalThis.fetch } = {}) {
|
|
|
|
|
const gc = this.config.grafanaConnector;
|
|
|
|
|
const title = String(gc.folderTitle || '').trim();
|
|
|
|
|
// No title configured → legacy behavior: use the explicit uid (may be '').
|
|
|
|
|
if (!title) return gc.folderUid || '';
|
|
|
|
|
if (this._resolvedFolderUid) return this._resolvedFolderUid;
|
|
|
|
|
if (typeof fetchImpl !== 'function') {
|
|
|
|
|
this.logger.warn('resolveFolderUid: no fetch implementation available; using configured folderUid');
|
|
|
|
|
return gc.folderUid || '';
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const uid = await this._lookupOrCreateFolder(title, fetchImpl);
|
|
|
|
|
if (uid) {
|
|
|
|
|
this._resolvedFolderUid = uid;
|
|
|
|
|
return uid;
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.logger.warn(`resolveFolderUid failed (${err?.message || err}); using configured folderUid`);
|
|
|
|
|
}
|
|
|
|
|
return gc.folderUid || '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async _lookupOrCreateFolder(title, fetchImpl) {
|
|
|
|
|
const url = this.grafanaFoldersUrl();
|
|
|
|
|
const headers = this._grafanaJsonHeaders();
|
|
|
|
|
|
|
|
|
|
const listRes = await fetchImpl(url, { method: 'GET', headers });
|
|
|
|
|
if (listRes?.ok) {
|
|
|
|
|
const folders = await listRes.json();
|
|
|
|
|
const match = Array.isArray(folders)
|
|
|
|
|
&& folders.find((f) => String(f?.title || '').trim().toLowerCase() === title.toLowerCase());
|
|
|
|
|
if (match?.uid) {
|
|
|
|
|
this.logger.info({ event: 'folder-resolved', outcome: 'found', title, uid: match.uid });
|
|
|
|
|
return match.uid;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
this.logger.warn(`resolveFolderUid: GET /api/folders -> ${listRes?.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const createRes = await fetchImpl(url, { method: 'POST', headers, body: JSON.stringify({ title }) });
|
|
|
|
|
if (createRes?.ok) {
|
|
|
|
|
const created = await createRes.json();
|
|
|
|
|
this.logger.info({ event: 'folder-resolved', outcome: 'created', title, uid: created?.uid });
|
|
|
|
|
return created?.uid || '';
|
|
|
|
|
}
|
|
|
|
|
this.logger.warn(`resolveFolderUid: POST /api/folders -> ${createRes?.status}`);
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 14:29:43 +01:00
|
|
|
buildDashboard({ nodeConfig, positionVsParent }) {
|
|
|
|
|
const softwareType =
|
|
|
|
|
nodeConfig?.functionality?.softwareType ||
|
|
|
|
|
nodeConfig?.functionality?.software_type ||
|
|
|
|
|
'measurement';
|
|
|
|
|
const nodeId = nodeConfig?.general?.id || nodeConfig?.general?.name || softwareType;
|
2026-05-27 09:45:37 +02:00
|
|
|
// 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}`;
|
2026-01-13 14:29:43 +01:00
|
|
|
const title = nodeConfig?.general?.name || String(nodeId);
|
|
|
|
|
|
2026-02-23 13:16:58 +01:00
|
|
|
// Missing templates are treated as non-fatal: we skip only that dashboard.
|
2026-05-28 10:32:52 +02:00
|
|
|
const templateVars = this._templateVarsForNode(softwareType, nodeConfig);
|
|
|
|
|
const dashboard = this.loadTemplate(softwareType, templateVars);
|
2026-02-23 13:16:58 +01:00
|
|
|
if (!dashboard) {
|
|
|
|
|
this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-01-13 14:29:43 +01:00
|
|
|
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 =
|
2026-03-11 11:13:44 +01:00
|
|
|
this.config.defaultBucket ||
|
|
|
|
|
this.config.bucketMap[String(positionVsParent)] ||
|
|
|
|
|
defaultBucketForPosition(positionVsParent);
|
2026-01-13 14:29:43 +01:00
|
|
|
|
|
|
|
|
updateTemplatingVar(dashboard, 'measurement', measurementName);
|
|
|
|
|
updateTemplatingVar(dashboard, 'bucket', bucket);
|
|
|
|
|
|
2026-05-27 16:09:29 +02:00
|
|
|
return { dashboard, uid, title, softwareType, nodeId, measurementName, bucket };
|
2026-01-13 14:29:43 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-26 17:53:42 +02:00
|
|
|
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;
|
2026-01-13 14:29:43 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
feat(dashboardapi): diff-skip regen via flows:started predicate (#36)
Subscribes to Node-RED's flows:started runtime event, caches the {diff}
payload on the dashboardAPI source, and short-circuits the child.register
handler when none of {dashboardAPI id, child id, grandchild ids} appears in
diff.added/changed/removed/rewired. Predicate verified by S1 spike (#32).
- src/nodeClass.js: _attachLifecycleHook subscribes, _attachCloseHandler
cleans up. No-op when RED.events isn't available (unit-test friendly).
- src/specificClass.js: subtreeChanged() predicate + subtreeIdsFor() helper.
- src/commands/handlers.js: registerChild consults predicate before composing;
logs {event:'regen-skipped', outcome:'no-diff'} when skipping.
- test/basic/slice36-diff-predicate.basic.test.js: 8 cases — null/empty diff,
affected/unaffected ids, tab-id over-triggering avoidance, grandchild
inclusion, no-grandchild case.
Cold start (no cached diff yet) always regenerates — safe default. Edge case
documented in #32: when a brand-new child is wired to a registered parent,
the new child's id is in diff.added but not yet in registeredChildren when
flows:started fires. Mitigation (b) per spike findings: one-deploy race
accepted for R&D — next deploy picks up the new registration.
Closes #36
2026-05-26 17:57:34 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 09:45:37 +02:00
|
|
|
// 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.
|
feat(dashboardapi): diff-skip regen via flows:started predicate (#36)
Subscribes to Node-RED's flows:started runtime event, caches the {diff}
payload on the dashboardAPI source, and short-circuits the child.register
handler when none of {dashboardAPI id, child id, grandchild ids} appears in
diff.added/changed/removed/rewired. Predicate verified by S1 spike (#32).
- src/nodeClass.js: _attachLifecycleHook subscribes, _attachCloseHandler
cleans up. No-op when RED.events isn't available (unit-test friendly).
- src/specificClass.js: subtreeChanged() predicate + subtreeIdsFor() helper.
- src/commands/handlers.js: registerChild consults predicate before composing;
logs {event:'regen-skipped', outcome:'no-diff'} when skipping.
- test/basic/slice36-diff-predicate.basic.test.js: 8 cases — null/empty diff,
affected/unaffected ids, tab-id over-triggering avoidance, grandchild
inclusion, no-grandchild case.
Cold start (no cached diff yet) always regenerates — safe default. Edge case
documented in #32: when a brand-new child is wired to a registered parent,
the new child's id is in diff.added but not yet in registeredChildren when
flows:started fires. Mitigation (b) per spike findings: one-deploy race
accepted for R&D — next deploy picks up the new registration.
Closes #36
2026-05-26 17:57:34 +02:00
|
|
|
subtreeIdsFor(dashboardApiNodeId, childSource) {
|
|
|
|
|
const ids = new Set();
|
|
|
|
|
if (dashboardApiNodeId) ids.add(dashboardApiNodeId);
|
2026-05-27 09:45:37 +02:00
|
|
|
this._collectSubtreeIds(childSource, ids, new Set());
|
feat(dashboardapi): diff-skip regen via flows:started predicate (#36)
Subscribes to Node-RED's flows:started runtime event, caches the {diff}
payload on the dashboardAPI source, and short-circuits the child.register
handler when none of {dashboardAPI id, child id, grandchild ids} appears in
diff.added/changed/removed/rewired. Predicate verified by S1 spike (#32).
- src/nodeClass.js: _attachLifecycleHook subscribes, _attachCloseHandler
cleans up. No-op when RED.events isn't available (unit-test friendly).
- src/specificClass.js: subtreeChanged() predicate + subtreeIdsFor() helper.
- src/commands/handlers.js: registerChild consults predicate before composing;
logs {event:'regen-skipped', outcome:'no-diff'} when skipping.
- test/basic/slice36-diff-predicate.basic.test.js: 8 cases — null/empty diff,
affected/unaffected ids, tab-id over-triggering avoidance, grandchild
inclusion, no-grandchild case.
Cold start (no cached diff yet) always regenerates — safe default. Edge case
documented in #32: when a brand-new child is wired to a registered parent,
the new child's id is in diff.added but not yet in registeredChildren when
flows:started fires. Mitigation (b) per spike findings: one-deploy race
accepted for R&D — next deploy picks up the new registration.
Closes #36
2026-05-26 17:57:34 +02:00
|
|
|
return ids;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 09:45:37 +02:00
|
|
|
_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.
|
2026-01-13 14:29:43 +01:00
|
|
|
generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) {
|
|
|
|
|
if (!rootSource?.config) {
|
2026-02-23 13:16:58 +01:00
|
|
|
this.logger.warn('generateDashboardsForGraph skipped: root source missing config');
|
|
|
|
|
return [];
|
2026-01-13 14:29:43 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-27 09:45:37 +02:00
|
|
|
const results = [];
|
|
|
|
|
this._composeNode(rootSource, includeChildren, results, new Set());
|
|
|
|
|
return results;
|
|
|
|
|
}
|
2026-01-13 14:29:43 +01:00
|
|
|
|
2026-05-27 09:45:37 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-01-13 14:29:43 +01:00
|
|
|
|
2026-05-27 09:45:37 +02:00
|
|
|
const position = nodeSource?.positionVsParent || nodeSource?.config?.functionality?.positionVsParent;
|
|
|
|
|
const nodeDash = this.buildDashboard({ nodeConfig: nodeSource.config, positionVsParent: position });
|
|
|
|
|
if (!nodeDash) return null;
|
|
|
|
|
results.push(nodeDash);
|
2026-01-13 14:29:43 +01:00
|
|
|
|
2026-05-27 09:45:37 +02:00
|
|
|
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);
|
2026-01-13 14:29:43 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-27 09:45:37 +02:00
|
|
|
this._dedupParentPanels(nodeDash, childDashes);
|
|
|
|
|
this._linkToChildren(nodeDash, children);
|
2026-05-27 16:09:29 +02:00
|
|
|
// Inject the per-pump fan-out panels AFTER dedup so they survive: these
|
|
|
|
|
// panels intentionally aggregate child data onto the parent dashboard
|
|
|
|
|
// (the operator wants every pump on one MGC graph), which is exactly what
|
|
|
|
|
// the no-duplication rule strips elsewhere. Run last so nothing removes them.
|
|
|
|
|
this._injectMachineGroupPumpPanels(nodeDash, children);
|
2026-05-27 09:45:37 +02:00
|
|
|
|
|
|
|
|
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,
|
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
|
|
|
});
|
|
|
|
|
}
|
2026-05-27 09:45:37 +02:00
|
|
|
}
|
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
|
|
|
|
2026-05-27 09:45:37 +02:00
|
|
|
_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,
|
|
|
|
|
});
|
2026-01-13 14:29:43 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-27 16:09:29 +02:00
|
|
|
|
|
|
|
|
// Software types that count as a "pump" child of a machine group. Mirrors the
|
|
|
|
|
// template-alias map: a rotatingMachine reports softwareType 'rotatingmachine'
|
|
|
|
|
// in production, 'machine' in tests / shared template.
|
|
|
|
|
static _PUMP_SOFTWARE_TYPES = new Set(['rotatingmachine', 'machine']);
|
|
|
|
|
|
|
|
|
|
// Replicate the measurement-name convention from outputUtils.formatMsg /
|
|
|
|
|
// buildDashboard so the dashboard queries the exact series each pump writes:
|
|
|
|
|
// `general.name` when set, else `<softwareType>_<id>`.
|
|
|
|
|
_measurementNameForConfig(config) {
|
|
|
|
|
const softwareType = config?.functionality?.softwareType || 'measurement';
|
|
|
|
|
return config?.general?.name || `${softwareType}_${config?.general?.id || softwareType}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Datasource block reused for injected panels. Pull it off an existing panel
|
|
|
|
|
// so the dashboard keeps a single influxdb datasource uid; fall back to the
|
|
|
|
|
// template's known uid if every panel was deduped away.
|
|
|
|
|
_datasourceFor(dashboard) {
|
|
|
|
|
const withDs = (dashboard.panels || []).find((p) => p?.datasource?.type === 'influxdb');
|
|
|
|
|
return withDs?.datasource || { type: 'influxdb', uid: 'cdzg44tv250jkd' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build the per-pump + group-aggregate timeseries panels for a machineGroup
|
|
|
|
|
// dashboard. The operator asked for one graph each of pump % control, pump
|
|
|
|
|
// predicted flow, and pump predicted power, with the group total folded in,
|
|
|
|
|
// the resolved demand overlaid on the flow graph, and the flow-capacity
|
|
|
|
|
// envelope drawn as dashed min/max lines.
|
|
|
|
|
//
|
|
|
|
|
// Per-pump series live in each pump's OWN InfluxDB measurement (not the
|
|
|
|
|
// MGC's), so the queries are generated at compose time from the known child
|
|
|
|
|
// topology. Pump series are kept by `_measurement` (legend = pump name);
|
|
|
|
|
// group series are kept by `_field` and renamed via byName overrides.
|
|
|
|
|
_injectMachineGroupPumpPanels(parentDash, children) {
|
|
|
|
|
if (!parentDash?.dashboard) return;
|
|
|
|
|
const st = String(parentDash.softwareType || '').toLowerCase();
|
|
|
|
|
if (st !== 'machinegroupcontrol' && st !== 'machinegroup') return;
|
|
|
|
|
|
|
|
|
|
const pumps = (children || [])
|
|
|
|
|
.map(({ childSource }) => childSource?.config)
|
|
|
|
|
.filter((c) => c && DashboardApi._PUMP_SOFTWARE_TYPES.has(
|
|
|
|
|
String(c?.functionality?.softwareType || '').toLowerCase()))
|
|
|
|
|
.map((c) => ({ measurement: this._measurementNameForConfig(c), title: c?.general?.name || c?.general?.id }));
|
|
|
|
|
|
|
|
|
|
if (pumps.length === 0) return; // No pumps wired → leave the static totals.
|
|
|
|
|
|
|
|
|
|
const dashboard = parentDash.dashboard;
|
|
|
|
|
const datasource = this._datasourceFor(dashboard);
|
|
|
|
|
// The richer flow/power panels below supersede the static group-total
|
|
|
|
|
// panels — drop them so the same series isn't drawn twice.
|
|
|
|
|
dashboard.panels = (dashboard.panels || []).filter(
|
|
|
|
|
(p) => p.title !== 'Total Flow' && p.title !== 'Total Power');
|
|
|
|
|
|
|
|
|
|
const measFilter = pumps.map((p) => `r._measurement == "${p.measurement}"`).join(' or ');
|
|
|
|
|
const nextId = Math.max(0, ...dashboard.panels.map((p) => Number(p.id) || 0)) + 1;
|
|
|
|
|
|
|
|
|
|
dashboard.panels.push(
|
|
|
|
|
this._pumpControlPanel({ datasource, measFilter, id: nextId, y: 6 }),
|
|
|
|
|
this._pumpFlowPanel({ datasource, measFilter, id: nextId + 1, y: 14 }),
|
|
|
|
|
this._pumpPowerPanel({ datasource, measFilter, id: nextId + 2, y: 22 }),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Injected-panel builders ──────────────────────────────────────────────
|
|
|
|
|
// All three use `${bucket}` / `${measurement}` template vars (resolved by
|
|
|
|
|
// Grafana from the dashboard's templating list) plus literal pump measurement
|
|
|
|
|
// names. v.timeRangeStart/Stop/windowPeriod are Grafana-supplied.
|
|
|
|
|
|
|
|
|
|
_baseTsPanel({ datasource, id, y, title, targets, overrides = [], defaults = {} }) {
|
|
|
|
|
return {
|
|
|
|
|
datasource,
|
|
|
|
|
fieldConfig: {
|
|
|
|
|
defaults: { custom: { drawStyle: 'line', lineWidth: 2, fillOpacity: 5, showPoints: 'never' }, ...defaults },
|
|
|
|
|
overrides,
|
|
|
|
|
},
|
|
|
|
|
gridPos: { h: 8, w: 24, x: 0, y },
|
|
|
|
|
id,
|
|
|
|
|
options: { legend: { displayMode: 'list', placement: 'bottom' }, tooltip: { mode: 'multi' } },
|
|
|
|
|
targets,
|
|
|
|
|
title,
|
|
|
|
|
type: 'timeseries',
|
|
|
|
|
// Empty emittedFields: these panels intentionally duplicate child series
|
|
|
|
|
// and must never be removed by the no-duplication dedup pass.
|
|
|
|
|
meta: { emittedFields: [], dynamic: 'mgc-pump-fanout' },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pump series kept by `_measurement` → one line per pump, legend = pump name.
|
|
|
|
|
// `field` is exact-matched by default; pass `regex:true` to match a 4-segment
|
|
|
|
|
// MeasurementContainer key whose childId varies per pump. rotatingMachine
|
|
|
|
|
// writes its own predictions under childId = node id (e.g.
|
|
|
|
|
// `flow.predicted.atequipment.<pumpId>`), NOT a fixed `default`, so the
|
|
|
|
|
// flow/power series must match the position prefix, not an exact key.
|
|
|
|
|
_perPumpTarget({ measFilter, field, refId, transform = '', regex = false }) {
|
|
|
|
|
const fieldFilter = regex ? `r._field =~ /${field}/` : `r._field == "${field}"`;
|
|
|
|
|
return {
|
|
|
|
|
refId,
|
|
|
|
|
query:
|
|
|
|
|
`from(bucket: "\${bucket}")\n` +
|
|
|
|
|
` |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n` +
|
|
|
|
|
` |> filter(fn:(r) => (${measFilter}) and ${fieldFilter})\n` +
|
|
|
|
|
` |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n` +
|
|
|
|
|
transform +
|
|
|
|
|
` |> keep(columns: ["_time", "_value", "_measurement"])`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Group series kept by `_field` → legend = field name, renamed via byName
|
|
|
|
|
// overrides. `fields` is OR-joined into one query.
|
|
|
|
|
_groupFieldsTarget({ fields, refId }) {
|
|
|
|
|
const filter = fields.map((f) => `r._field == "${f}"`).join(' or ');
|
|
|
|
|
return {
|
|
|
|
|
refId,
|
|
|
|
|
query:
|
|
|
|
|
`from(bucket: "\${bucket}")\n` +
|
|
|
|
|
` |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n` +
|
|
|
|
|
` |> filter(fn:(r) => r._measurement == "\${measurement}" and (${filter}))\n` +
|
|
|
|
|
` |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n` +
|
|
|
|
|
` |> keep(columns: ["_time", "_value", "_field"])`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_byName(name, properties) {
|
|
|
|
|
return { matcher: { id: 'byName', options: name }, properties };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_pumpControlPanel({ datasource, measFilter, id, y }) {
|
|
|
|
|
// Two series per pump so an operator can see at a glance whether each pump
|
|
|
|
|
// actually moved to where the MGC told it:
|
|
|
|
|
// • realized position — the bare `ctrl` field (getCurrentPosition), solid.
|
|
|
|
|
// • commanded setpoint — `ctrl.predicted.atequipment.<pumpId>`, the % the
|
|
|
|
|
// pump computed from the MGC flow command (calcCtrl reverse curve),
|
|
|
|
|
// drawn dashed. childId varies per pump, so match the position prefix.
|
|
|
|
|
// Both are already 0..100 %, so they map straight onto a % axis — no scaling.
|
|
|
|
|
// Each series' `_measurement` is suffixed so the legend distinguishes the
|
|
|
|
|
// two lines per pump ("Pump A (realized)" vs "Pump A (setpoint)").
|
|
|
|
|
const label = (name) =>
|
|
|
|
|
` |> map(fn: (r) => ({ r with _measurement: r._measurement + " (${name})" }))\n`;
|
|
|
|
|
return this._baseTsPanel({
|
|
|
|
|
datasource, id, y,
|
|
|
|
|
title: 'Pump % Control',
|
|
|
|
|
defaults: { unit: 'percent', min: 0, max: 100 },
|
|
|
|
|
targets: [
|
|
|
|
|
this._perPumpTarget({ measFilter, field: 'ctrl', refId: 'A', transform: label('realized') }),
|
|
|
|
|
this._perPumpTarget({
|
|
|
|
|
measFilter, field: '^ctrl\\.predicted\\.atequipment\\.', refId: 'B',
|
|
|
|
|
regex: true, transform: label('setpoint'),
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
overrides: [{
|
|
|
|
|
matcher: { id: 'byRegexp', options: '.*\\(setpoint\\)' },
|
|
|
|
|
properties: [{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [6, 6] } }],
|
|
|
|
|
}],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_pumpFlowPanel({ datasource, measFilter, id, y }) {
|
|
|
|
|
return this._baseTsPanel({
|
|
|
|
|
datasource, id, y,
|
|
|
|
|
title: 'Pump Predicted Flow vs Demand',
|
|
|
|
|
defaults: { unit: 'm3/h' },
|
|
|
|
|
targets: [
|
|
|
|
|
this._perPumpTarget({ measFilter, field: '^flow\\.predicted\\.atequipment\\.', refId: 'A', regex: true }),
|
|
|
|
|
this._groupFieldsTarget({
|
|
|
|
|
refId: 'B',
|
|
|
|
|
fields: ['atEquipment_predicted_flow', 'demandFlow', 'demandPct', 'flowCapacityMin', 'flowCapacityMax'],
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
overrides: [
|
|
|
|
|
this._byName('atEquipment_predicted_flow', [
|
|
|
|
|
{ id: 'displayName', value: 'Total flow' },
|
|
|
|
|
{ id: 'custom.lineWidth', value: 3 },
|
|
|
|
|
]),
|
|
|
|
|
this._byName('demandFlow', [
|
|
|
|
|
{ id: 'displayName', value: 'Flow demand (setpoint)' },
|
|
|
|
|
{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [6, 6] } },
|
|
|
|
|
{ id: 'color', value: { mode: 'fixed', fixedColor: 'blue' } },
|
|
|
|
|
]),
|
|
|
|
|
this._byName('demandPct', [
|
|
|
|
|
{ id: 'displayName', value: 'Demand %' },
|
|
|
|
|
{ id: 'unit', value: 'percent' },
|
|
|
|
|
{ id: 'custom.axisPlacement', value: 'right' },
|
|
|
|
|
{ id: 'custom.axisLabel', value: '% control' },
|
|
|
|
|
{ id: 'color', value: { mode: 'fixed', fixedColor: 'purple' } },
|
|
|
|
|
]),
|
|
|
|
|
this._byName('flowCapacityMin', [
|
|
|
|
|
{ id: 'displayName', value: 'Capacity min' },
|
|
|
|
|
{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [10, 10] } },
|
|
|
|
|
{ id: 'custom.fillOpacity', value: 0 },
|
|
|
|
|
{ id: 'color', value: { mode: 'fixed', fixedColor: 'orange' } },
|
|
|
|
|
]),
|
|
|
|
|
this._byName('flowCapacityMax', [
|
|
|
|
|
{ id: 'displayName', value: 'Capacity max' },
|
|
|
|
|
{ id: 'custom.lineStyle', value: { fill: 'dash', dash: [10, 10] } },
|
|
|
|
|
{ id: 'custom.fillOpacity', value: 0 },
|
|
|
|
|
{ id: 'color', value: { mode: 'fixed', fixedColor: 'red' } },
|
|
|
|
|
]),
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_pumpPowerPanel({ datasource, measFilter, id, y }) {
|
|
|
|
|
return this._baseTsPanel({
|
|
|
|
|
datasource, id, y,
|
|
|
|
|
title: 'Pump Predicted Power',
|
|
|
|
|
defaults: { unit: 'kwatt' },
|
|
|
|
|
targets: [
|
|
|
|
|
this._perPumpTarget({ measFilter, field: '^power\\.predicted\\.atequipment\\.', refId: 'A', regex: true }),
|
|
|
|
|
this._groupFieldsTarget({ refId: 'B', fields: ['atEquipment_predicted_power'] }),
|
|
|
|
|
],
|
|
|
|
|
overrides: [
|
|
|
|
|
this._byName('atEquipment_predicted_power', [
|
|
|
|
|
{ id: 'displayName', value: 'Total power' },
|
|
|
|
|
{ id: 'custom.lineWidth', value: 3 },
|
|
|
|
|
]),
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-13 14:29:43 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = DashboardApi;
|