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); } // 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'; if (pos === 'downstream') return 'lvl3'; return 'lvl2'; } // 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 )); } 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 || '', // 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 || '', 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 ); // 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()); } _templatesDir() { return path.join(__dirname, '..', 'config'); } _templateFileForSoftwareType(softwareType) { const st = String(softwareType || '').trim(); const candidates = [ TEMPLATE_FILE_BY_SOFTWARE_TYPE[st.toLowerCase()], `${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; } this.logger.warn(`No dashboard template found for softwareType=${st}`); return null; } loadTemplate(softwareType, templateVars = null) { const templatePath = this._templateFileForSoftwareType(softwareType); if (!templatePath) return null; 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); return JSON.parse(raw); } // 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); // 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; 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, }; } // 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`; } 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 ''; } buildDashboard({ nodeConfig, positionVsParent }) { const softwareType = nodeConfig?.functionality?.softwareType || nodeConfig?.functionality?.software_type || 'measurement'; const nodeId = nodeConfig?.general?.id || nodeConfig?.general?.name || softwareType; // Mirror outputUtils.formatMsg: telemetry is written under general.name when // set, falling back to `_`. 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. const templateVars = this._templateVarsForNode(softwareType, nodeConfig); const dashboard = this.loadTemplate(softwareType, templateVars); 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, bucket }; } 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 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); 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 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); // 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); 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, }); } } // 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 `_`. _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.`), 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.`, 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 }, ]), ], }); } } module.exports = DashboardApi;