feat(dashboardAPI): slice47 MGC pump panel telemetry + tests
- specificClass updates for MGC per-pump panel sources. - Output manifest + slice47 basic test for the pump-panel outputs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -194,7 +194,7 @@ class DashboardApi {
|
||||
updateTemplatingVar(dashboard, 'measurement', measurementName);
|
||||
updateTemplatingVar(dashboard, 'bucket', bucket);
|
||||
|
||||
return { dashboard, uid, title, softwareType, nodeId, measurementName };
|
||||
return { dashboard, uid, title, softwareType, nodeId, measurementName, bucket };
|
||||
}
|
||||
|
||||
buildUpsertRequest({ dashboard, folderId, folderUid, overwrite = true }) {
|
||||
@@ -299,6 +299,11 @@ class DashboardApi {
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -352,6 +357,223 @@ class DashboardApi {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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 },
|
||||
]),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DashboardApi;
|
||||
|
||||
@@ -48,8 +48,9 @@ dashboardAPI is a **sink** for `child.register` messages, not a source — it do
|
||||
|
||||
| Method | Return shape | Populated states | Degraded states | Test |
|
||||
|---|---|---|---|---|
|
||||
| `buildDashboard(opts)` | `{ dashboard, uid, title, softwareType, nodeId, measurementName }` or `null`; `measurementName` mirrors `outputUtils.formatMsg` (`general.name` \|\| `<softwareType>_<id>`) so the dashboard `_measurement` var matches the telemetry series | success (name set + name empty/fallback) | `null` when no template for softwareType | `test/basic/slice43-output-manifest.basic.test.js`, `test/basic/slice46-measurement-name-parity.basic.test.js` |
|
||||
| `generateDashboardsForGraph(root)` | flat pre-order array of `buildDashboard` results (root first, then full descendant subtree); per-parent dedup + links applied at every level | 0..N children, 3-level tree, diamond, cycle | empty array when root config missing | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js`, `test/basic/slice44-recursive-discovery.basic.test.js` |
|
||||
| `buildDashboard(opts)` | `{ dashboard, uid, title, softwareType, nodeId, measurementName, bucket }` or `null`; `measurementName` mirrors `outputUtils.formatMsg` (`general.name` \|\| `<softwareType>_<id>`) so the dashboard `_measurement` var matches the telemetry series; `bucket` is the resolved Influx bucket | success (name set + name empty/fallback) | `null` when no template for softwareType | `test/basic/slice43-output-manifest.basic.test.js`, `test/basic/slice46-measurement-name-parity.basic.test.js` |
|
||||
| `generateDashboardsForGraph(root)` | flat pre-order array of `buildDashboard` results (root first, then full descendant subtree); per-parent dedup + links applied at every level; machineGroup roots additionally get per-pump fan-out panels injected (see below) | 0..N children, 3-level tree, diamond, cycle | empty array when root config missing | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js`, `test/basic/slice44-recursive-discovery.basic.test.js` |
|
||||
| `_injectMachineGroupPumpPanels(parentDash, children)` | mutates an MGC dashboard in place: replaces the static Total Flow/Power panels with 3 timeseries panels (Pump % Control, Pump Predicted Flow vs Demand, Pump Predicted Power) whose queries are generated from the child-pump measurement names. Panels carry `meta.emittedFields: []` so they survive the dedup pass | MGC with ≥1 rotatingMachine child | no-op for non-MGC dashboards or MGC with zero pump children (static totals retained) | `test/basic/slice47-mgc-pump-panels.basic.test.js` |
|
||||
| `subtreeChanged(diff, ids)` | boolean | id-in-diff, no-id-in-diff | null diff → true (cold start) | `test/basic/slice36-diff-predicate.basic.test.js` |
|
||||
| `subtreeIdsFor(myId, child)` | Set\<string\> | myId + every id in the child's full subtree (recurses all levels, cycle-safe) | myId only when child has no children | `test/basic/slice36-diff-predicate.basic.test.js`, `test/basic/slice44-recursive-discovery.basic.test.js` |
|
||||
| `collectEmittedFields(dashboard)` | Set\<string\> | populated dashboard | empty set for `null`/`{}`/`{panels:[]}` | `test/basic/slice37-emitted-fields.basic.test.js` |
|
||||
|
||||
156
test/basic/slice47-mgc-pump-panels.basic.test.js
Normal file
156
test/basic/slice47-mgc-pump-panels.basic.test.js
Normal file
@@ -0,0 +1,156 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const DashboardApi = require('../../src/specificClass.js');
|
||||
|
||||
// Build an MGC root with N rotatingMachine children, compose the graph, and
|
||||
// return the MGC dashboard (results[0]).
|
||||
function composeMgcWith(pumpDefs) {
|
||||
const api = new DashboardApi({});
|
||||
const entries = pumpDefs.map((p) => [p.id, {
|
||||
child: { config: { general: { id: p.id, name: p.name }, functionality: { softwareType: p.softwareType || 'machine', positionVsParent: 'downstream' } } },
|
||||
position: 'downstream',
|
||||
}]);
|
||||
const root = {
|
||||
config: { general: { id: 'mgc-1', name: 'MGC' }, functionality: { softwareType: 'machineGroupControl', positionVsParent: 'atequipment' } },
|
||||
childRegistrationUtils: { registeredChildren: new Map(entries) },
|
||||
};
|
||||
return { api, dash: api.generateDashboardsForGraph(root)[0].dashboard };
|
||||
}
|
||||
|
||||
const PUMPS = [
|
||||
{ id: 'pump-a', name: 'Pump A' },
|
||||
{ id: 'pump-b', name: 'Pump B' },
|
||||
];
|
||||
|
||||
test('MGC dashboard gains the three pump fan-out panels', () => {
|
||||
const { dash } = composeMgcWith(PUMPS);
|
||||
const titles = dash.panels.filter((p) => p.type === 'timeseries').map((p) => p.title);
|
||||
assert.ok(titles.includes('Pump % Control'), 'missing % control panel');
|
||||
assert.ok(titles.includes('Pump Predicted Flow vs Demand'), 'missing flow panel');
|
||||
assert.ok(titles.includes('Pump Predicted Power'), 'missing power panel');
|
||||
});
|
||||
|
||||
test('static group-total panels are replaced by the richer fan-out panels', () => {
|
||||
const { dash } = composeMgcWith(PUMPS);
|
||||
const titles = dash.panels.map((p) => p.title);
|
||||
assert.ok(!titles.includes('Total Flow'), 'static Total Flow should be removed');
|
||||
assert.ok(!titles.includes('Total Power'), 'static Total Power should be removed');
|
||||
});
|
||||
|
||||
test('% control query targets every pump measurement; ctrl is already percent (no scaling)', () => {
|
||||
const { dash } = composeMgcWith(PUMPS);
|
||||
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
|
||||
const q = panel.targets[0].query;
|
||||
assert.match(q, /r\._measurement == "Pump A"/);
|
||||
assert.match(q, /r\._measurement == "Pump B"/);
|
||||
assert.match(q, /r\._field == "ctrl"/);
|
||||
assert.ok(!/_value \* 100/.test(q), 'ctrl is 0..100 already — must NOT be ×100 scaled');
|
||||
assert.equal(panel.fieldConfig.defaults.unit, 'percent');
|
||||
});
|
||||
|
||||
test('% control plots both realized position and commanded setpoint per pump', () => {
|
||||
const { dash } = composeMgcWith(PUMPS);
|
||||
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
|
||||
const realized = panel.targets.find((t) => /r\._field == "ctrl"/.test(t.query));
|
||||
const setpoint = panel.targets.find((t) => /ctrl\\\.predicted\\\.atequipment/.test(t.query));
|
||||
assert.ok(realized, 'missing realized-position (ctrl) series');
|
||||
assert.ok(setpoint, 'missing commanded-setpoint (ctrl.predicted.atequipment) series');
|
||||
// childId varies per pump → setpoint must be a regex (=~) prefix match.
|
||||
assert.match(setpoint.query, /r\._field =~ \/\^ctrl\\\.predicted\\\.atequipment\\\.\//);
|
||||
assert.ok(!/\.default/.test(setpoint.query), 'must not hardcode childId .default');
|
||||
// Legend disambiguation: each series suffixes its _measurement.
|
||||
assert.match(realized.query, /\(realized\)/);
|
||||
assert.match(setpoint.query, /\(setpoint\)/);
|
||||
});
|
||||
|
||||
test('setpoint series is drawn dashed to distinguish it from realized', () => {
|
||||
const { dash } = composeMgcWith(PUMPS);
|
||||
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
|
||||
const ov = panel.fieldConfig.overrides.find((o) => /setpoint/.test(o.matcher.options));
|
||||
assert.ok(ov, 'missing dashed override for setpoint series');
|
||||
assert.equal(ov.matcher.id, 'byRegexp');
|
||||
const lineStyle = ov.properties.find((p) => p.id === 'custom.lineStyle')?.value;
|
||||
assert.equal(lineStyle?.fill, 'dash', 'setpoint must be dashed');
|
||||
});
|
||||
|
||||
test('per-pump flow/power match the position prefix (childId varies per pump)', () => {
|
||||
const { dash } = composeMgcWith(PUMPS);
|
||||
const flowQ = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand').targets[0].query;
|
||||
const powerQ = dash.panels.find((p) => p.title === 'Pump Predicted Power').targets[0].query;
|
||||
// Regex field match (=~), not an exact `.default` key, so it catches
|
||||
// `flow.predicted.atequipment.<pumpId>` whatever the childId is.
|
||||
assert.match(flowQ, /r\._field =~ \/\^flow\\\.predicted\\\.atequipment\\\.\//);
|
||||
assert.match(powerQ, /r\._field =~ \/\^power\\\.predicted\\\.atequipment\\\.\//);
|
||||
assert.ok(!/\.default/.test(flowQ), 'must not hardcode childId .default');
|
||||
});
|
||||
|
||||
test('measurement name falls back to <softwareType>_<id> when name is unset', () => {
|
||||
const { dash } = composeMgcWith([{ id: 'p9', softwareType: 'rotatingmachine' }]);
|
||||
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
|
||||
assert.match(panel.targets[0].query, /r\._measurement == "rotatingmachine_p9"/);
|
||||
});
|
||||
|
||||
test('flow panel folds in total flow, demand setpoint, demand %, and per-pump flow', () => {
|
||||
const { dash } = composeMgcWith(PUMPS);
|
||||
const panel = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand');
|
||||
const queries = panel.targets.map((t) => t.query).join('\n');
|
||||
assert.match(queries, /flow\\\.predicted\\\.atequipment/, 'per-pump flow field');
|
||||
assert.match(queries, /atEquipment_predicted_flow/, 'group total flow field');
|
||||
assert.match(queries, /demandFlow/, 'resolved flow setpoint field');
|
||||
assert.match(queries, /demandPct/, 'demand percent field');
|
||||
});
|
||||
|
||||
test('flow capacity envelope is drawn as dashed min/max lines', () => {
|
||||
const { dash } = composeMgcWith(PUMPS);
|
||||
const panel = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand');
|
||||
const byName = Object.fromEntries(
|
||||
panel.fieldConfig.overrides.map((o) => [o.matcher.options, o.properties]));
|
||||
for (const cap of ['flowCapacityMin', 'flowCapacityMax']) {
|
||||
const props = byName[cap];
|
||||
assert.ok(props, `missing override for ${cap}`);
|
||||
const lineStyle = props.find((p) => p.id === 'custom.lineStyle')?.value;
|
||||
assert.equal(lineStyle?.fill, 'dash', `${cap} must be dashed`);
|
||||
}
|
||||
});
|
||||
|
||||
test('demand % is placed on a secondary (right) axis in percent', () => {
|
||||
const { dash } = composeMgcWith(PUMPS);
|
||||
const panel = dash.panels.find((p) => p.title === 'Pump Predicted Flow vs Demand');
|
||||
const props = panel.fieldConfig.overrides.find((o) => o.matcher.options === 'demandPct')?.properties || [];
|
||||
assert.equal(props.find((p) => p.id === 'unit')?.value, 'percent');
|
||||
assert.equal(props.find((p) => p.id === 'custom.axisPlacement')?.value, 'right');
|
||||
});
|
||||
|
||||
test('power panel folds total power in with per-pump power', () => {
|
||||
const { dash } = composeMgcWith(PUMPS);
|
||||
const panel = dash.panels.find((p) => p.title === 'Pump Predicted Power');
|
||||
const queries = panel.targets.map((t) => t.query).join('\n');
|
||||
assert.match(queries, /power\\\.predicted\\\.atequipment/, 'per-pump power field');
|
||||
assert.match(queries, /atEquipment_predicted_power/, 'group total power field');
|
||||
});
|
||||
|
||||
test('injected panels are exempt from the no-duplication dedup (empty emittedFields)', () => {
|
||||
const { dash } = composeMgcWith(PUMPS);
|
||||
const dynamic = dash.panels.filter((p) => p?.meta?.dynamic === 'mgc-pump-fanout');
|
||||
assert.equal(dynamic.length, 3);
|
||||
for (const p of dynamic) assert.deepEqual(p.meta.emittedFields, []);
|
||||
});
|
||||
|
||||
test('a machineGroup with no pump children keeps the static template panels', () => {
|
||||
const { dash } = composeMgcWith([
|
||||
{ id: 'm1', name: 'Meter', softwareType: 'measurement' },
|
||||
]);
|
||||
const titles = dash.panels.map((p) => p.title);
|
||||
assert.ok(titles.includes('Total Flow'), 'static totals must remain when no pumps');
|
||||
assert.ok(!titles.includes('Pump % Control'), 'no fan-out panels without pumps');
|
||||
});
|
||||
|
||||
test('injected panels reuse the dashboard influxdb datasource uid', () => {
|
||||
const { dash } = composeMgcWith(PUMPS);
|
||||
const panel = dash.panels.find((p) => p.title === 'Pump % Control');
|
||||
assert.equal(panel.datasource.type, 'influxdb');
|
||||
assert.equal(panel.datasource.uid, 'cdzg44tv250jkd');
|
||||
});
|
||||
Reference in New Issue
Block a user