Compare commits
10 Commits
90536d631d
...
533f74fe7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
533f74fe7e | ||
|
|
a16f526964 | ||
|
|
8afc6b9779 | ||
|
|
193f913eb1 | ||
|
|
41a20d4679 | ||
|
|
8a26e17780 | ||
|
|
3cd749bf37 | ||
|
|
70151e52ec | ||
|
|
b3972d4a2f | ||
|
|
3529c9f970 |
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dashboardAPI",
|
"name": "dashboardAPI",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "EVOLV Grafana dashboard generator (Node-RED node).",
|
"description": "EVOLV Grafana dashboard generator (Node-RED node).",
|
||||||
"main": "dashboardAPI.js",
|
"main": "dashboardAPI.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ function defaultBucketForPosition(positionVsParent) {
|
|||||||
return 'lvl2';
|
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) {
|
function updateTemplatingVar(dashboard, varName, value) {
|
||||||
const list = dashboard?.templating?.list;
|
const list = dashboard?.templating?.list;
|
||||||
if (!Array.isArray(list)) return;
|
if (!Array.isArray(list)) return;
|
||||||
@@ -140,13 +154,180 @@ class DashboardApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadTemplate(softwareType) {
|
loadTemplate(softwareType, templateVars = null) {
|
||||||
const templatePath = this._templateFileForSoftwareType(softwareType);
|
const templatePath = this._templateFileForSoftwareType(softwareType);
|
||||||
if (!templatePath) return null;
|
if (!templatePath) return null;
|
||||||
const raw = fs.readFileSync(templatePath, 'utf8');
|
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);
|
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);
|
||||||
|
|
||||||
|
// Reference frame: 400 (logical w) x 760 (logical h) px. With every
|
||||||
|
// canvas element using `constraint: { horizontal: scale, vertical: scale }`,
|
||||||
|
// Grafana interprets placement values as PERCENTAGES of the panel size,
|
||||||
|
// not pixels — so the basin stretches to fill the card at any viewport
|
||||||
|
// and stays centered without letterboxing.
|
||||||
|
// Tank reference: rim at y=48px (6.32%), floor at y=712px (93.68%),
|
||||||
|
// centred vertically with 48px top/bottom margins. Margins are sized
|
||||||
|
// so the size-14 'rim (X m)' and 'floor (0.00 m)' captions fit with
|
||||||
|
// ~10 px clearance from the topmost/bottommost threshold line — labels
|
||||||
|
// can never collide with a line at any basin geometry.
|
||||||
|
const FRAME_W = 400, FRAME_H = 760;
|
||||||
|
const TANK_TOP = 48, TANK_BOT = 712, TANK_H = TANK_BOT - TANK_TOP;
|
||||||
|
const yp = (v) => +(v / FRAME_H * 100).toFixed(2);
|
||||||
|
const xp = (v) => +(v / FRAME_W * 100).toFixed(2);
|
||||||
|
const hp = (v) => +(v / FRAME_H * 100).toFixed(2);
|
||||||
|
const wp = (v) => +(v / FRAME_W * 100).toFixed(2);
|
||||||
|
const yFor = (v) => +(TANK_BOT - (v / heightBasin) * TANK_H).toFixed(2);
|
||||||
|
const tyFor = (yLine) => +(yLine - 8).toFixed(2); // centre 16px text on the line
|
||||||
|
|
||||||
|
let y_overflow = yFor(overflowLevel);
|
||||||
|
let y_highSafety = yFor(highSafetyLevel);
|
||||||
|
let y_inflow = yFor(inflowLevel);
|
||||||
|
let y_dryRun = yFor(dryRunLevel);
|
||||||
|
let y_outflow = yFor(outflowLevel);
|
||||||
|
|
||||||
|
// Enforce a minimum visual gap between adjacent threshold lines so labels
|
||||||
|
// can always sit cleanly between them — independent of how close the
|
||||||
|
// underlying physical thresholds are. Slight geometric distortion is
|
||||||
|
// acceptable: the tank visual conveys ORDERING and ZONE STRUCTURE, not
|
||||||
|
// exact-scale level measurement. Dashed/value labels carry the true
|
||||||
|
// numeric values.
|
||||||
|
const MIN_LINE_GAP = 28; // px (≈3.7% of 760-tall frame, > LABEL_H + 2)
|
||||||
|
const sorted = [
|
||||||
|
{ id: 'overflow', get: () => y_overflow, set: (v) => (y_overflow = v) },
|
||||||
|
{ id: 'highSafety', get: () => y_highSafety, set: (v) => (y_highSafety = v) },
|
||||||
|
{ id: 'inflow', get: () => y_inflow, set: (v) => (y_inflow = v) },
|
||||||
|
{ id: 'dryRun', get: () => y_dryRun, set: (v) => (y_dryRun = v) },
|
||||||
|
{ id: 'outflow', get: () => y_outflow, set: (v) => (y_outflow = v) },
|
||||||
|
].sort((a, b) => a.get() - b.get());
|
||||||
|
// Push down to enforce min gap (anchor: topmost line)
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
const minY = sorted[i - 1].get() + MIN_LINE_GAP;
|
||||||
|
if (sorted[i].get() < minY) sorted[i].set(minY);
|
||||||
|
}
|
||||||
|
// If the last (lowest) line went past the floor, shift the whole stack up.
|
||||||
|
const overshoot = sorted[sorted.length - 1].get() - TANK_BOT;
|
||||||
|
if (overshoot > 0) {
|
||||||
|
for (const item of sorted) item.set(item.get() - overshoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label y-positions: labels sit either ABOVE or BELOW their threshold
|
||||||
|
// line, never on it. Each label is offset by ABOVE_OFFSET=22 px above
|
||||||
|
// its line by default (16 px tall label + 6 px clear above the line).
|
||||||
|
// If two thresholds are too close together for both labels to fit ABOVE
|
||||||
|
// their lines (label of the lower one would cross the upper line), the
|
||||||
|
// lower one's label flips BELOW its line instead. With the current
|
||||||
|
// basin (dryRun=2% means dryRunLevel sits right on outflowLevel; high-
|
||||||
|
// Safety=98% puts it just under overflowLevel) this naturally puts
|
||||||
|
// highSafety BELOW and outflow BELOW.
|
||||||
|
const LABEL_H = 16;
|
||||||
|
const ABOVE_OFFSET = 22; // label_top = line_y - 22 (6 px clear above line)
|
||||||
|
const BELOW_OFFSET = 6; // label_top = line_y + 6 (6 px clear below line)
|
||||||
|
const MIN_DIST_FOR_ABOVE = 24; // if distance to upper line < this, try below
|
||||||
|
const lines = [
|
||||||
|
{ id: 'overflow', line: y_overflow },
|
||||||
|
{ id: 'highSafety', line: y_highSafety },
|
||||||
|
{ id: 'inflow', line: y_inflow },
|
||||||
|
{ id: 'dryRun', line: y_dryRun },
|
||||||
|
{ id: 'outflow', line: y_outflow },
|
||||||
|
].sort((a, b) => a.line - b.line);
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const prev = i > 0 ? lines[i - 1] : null;
|
||||||
|
const tooClose = prev && (lines[i].line - prev.line) < MIN_DIST_FOR_ABOVE;
|
||||||
|
if (tooClose) {
|
||||||
|
// Default to BELOW unless the label would be clipped by the tank
|
||||||
|
// floor (thresholds at the very bottom — dryRun=tiny% means
|
||||||
|
// dryRunLevel sits right on the floor). Then stack ABOVE the
|
||||||
|
// previous label instead, even if it slightly crowds its own line.
|
||||||
|
const belowY = lines[i].line + BELOW_OFFSET;
|
||||||
|
if (belowY + LABEL_H <= TANK_BOT) {
|
||||||
|
lines[i].y = belowY;
|
||||||
|
} else {
|
||||||
|
lines[i].y = prev.y + LABEL_H + 2; // stack above with 2 px gap
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines[i].y = lines[i].line - ABOVE_OFFSET;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ty = Object.fromEntries(lines.map((l) => [l.id, +l.y.toFixed(2)]));
|
||||||
|
|
||||||
|
// Canvas elements use `constraint: { horizontal: scale, vertical: scale }`
|
||||||
|
// with margin-style placement (top + bottom + left + right, all %s of the
|
||||||
|
// panel). Bottom = % from panel bottom, top = % from panel top. Width and
|
||||||
|
// height are derived as 100 - top - bottom, etc.
|
||||||
|
// We emit *all* placement margins precomputed so the JSON template stays
|
||||||
|
// declarative.
|
||||||
|
const LABEL_H_PCT = hp(16); // 16 px label height as % of frame
|
||||||
|
const LINE_H_PCT = hp(1); // 1 px line height as % of frame
|
||||||
|
const bMargin = (top, h) => +(100 - top - h).toFixed(2);
|
||||||
|
const lineBottom = (lineY) => +(100 - yp(lineY) - LINE_H_PCT).toFixed(2);
|
||||||
|
const labelBottom = (lblY) => +(100 - yp(lblY) - LABEL_H_PCT).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),
|
||||||
|
// Threshold line top margins (% from panel top)
|
||||||
|
y_overflow: yp(y_overflow),
|
||||||
|
y_highSafety: yp(y_highSafety),
|
||||||
|
y_inflow: yp(y_inflow),
|
||||||
|
y_dryRun: yp(y_dryRun),
|
||||||
|
y_outflow: yp(y_outflow),
|
||||||
|
// Threshold line bottom margins (% from panel bottom)
|
||||||
|
yb_overflow: lineBottom(y_overflow),
|
||||||
|
yb_highSafety: lineBottom(y_highSafety),
|
||||||
|
yb_inflow: lineBottom(y_inflow),
|
||||||
|
yb_dryRun: lineBottom(y_dryRun),
|
||||||
|
yb_outflow: lineBottom(y_outflow),
|
||||||
|
// Zone bottom margins (zones end at the next line below)
|
||||||
|
zb_spill: +(100 - yp(y_overflow)).toFixed(2), // ends at overflow line
|
||||||
|
zb_highSafety: +(100 - yp(y_highSafety)).toFixed(2), // ends at highSafety line
|
||||||
|
zb_operating: +(100 - yp(y_outflow)).toFixed(2), // ends at outflow line
|
||||||
|
zb_dead: +(100 - yp(TANK_BOT)).toFixed(2), // ends at floor
|
||||||
|
// Label top margins (% from panel top) and bottom margins (% from panel bottom)
|
||||||
|
ty_overflow: yp(ty.overflow),
|
||||||
|
ty_highSafety: yp(ty.highSafety),
|
||||||
|
ty_inflow: yp(ty.inflow),
|
||||||
|
ty_dryRun: yp(ty.dryRun),
|
||||||
|
ty_outflow: yp(ty.outflow),
|
||||||
|
tyb_overflow: labelBottom(ty.overflow),
|
||||||
|
tyb_highSafety: labelBottom(ty.highSafety),
|
||||||
|
tyb_inflow: labelBottom(ty.inflow),
|
||||||
|
tyb_dryRun: labelBottom(ty.dryRun),
|
||||||
|
tyb_outflow: labelBottom(ty.outflow),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Collect every `meta.emittedFields` declared by panels in a template.
|
// Collect every `meta.emittedFields` declared by panels in a template.
|
||||||
// Used by #39's parent panel filter — a parent panel whose emittedFields
|
// Used by #39's parent panel filter — a parent panel whose emittedFields
|
||||||
// are fully covered by its children's panels is removed.
|
// are fully covered by its children's panels is removed.
|
||||||
@@ -248,7 +429,8 @@ class DashboardApi {
|
|||||||
const title = nodeConfig?.general?.name || String(nodeId);
|
const title = nodeConfig?.general?.name || String(nodeId);
|
||||||
|
|
||||||
// Missing templates are treated as non-fatal: we skip only that dashboard.
|
// Missing templates are treated as non-fatal: we skip only that dashboard.
|
||||||
const dashboard = this.loadTemplate(softwareType);
|
const templateVars = this._templateVarsForNode(softwareType, nodeConfig);
|
||||||
|
const dashboard = this.loadTemplate(softwareType, templateVars);
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`);
|
this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user