6 Commits

Author SHA1 Message Date
znetsixe
de957cb971 fix(dashboardAPI): resolve InfluxDB datasource uid at push time
Templates baked in a hardcoded influxdb datasource uid that only matched the
Grafana the templates were authored against. Any other Grafana (fresh laptop,
VPS, rebuilt instance) rendered every panel as "Datasource <uid> not found".

resolveDatasourceUid() queries GET /api/datasources, picks the first influxdb
one, and caches the result. rewriteDatasourceUid() then walks panels, nested
row panels, panel.targets[], and templating.list[] and rewrites every influxdb
uid before the dashboard is pushed. Annotation datasources (type: "grafana")
and template-variable refs ($datasource) are left alone. Failure is silent and
panels keep the template uid, so behavior is never worse than before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:33:22 +02:00
znetsixe
533f74fe7e chore(dashboardAPI): bigger rim/floor labels with guaranteed clearance
Rim ('rim (X m)') and floor ('floor (0.00 m)') captions now use the same
size 14 font as the threshold labels (was size 10 — visibly smaller and
hard to read on a high-resolution dashboard). To make room for the larger
text while ensuring rim/floor can NEVER overlap the topmost (overflow) or
bottommost (outflow) threshold lines at any basin geometry, the tank's
vertical margins are bumped from 40 px to 48 px each:

  TANK_TOP: 40 -> 48,  TANK_BOT: 720 -> 712,  TANK_H: 680 -> 664

Rim placement: top:1, bottom:95 (4 % tall band at the top, spanning 7.6 -> 38 px).
Floor placement: top:95, bottom:1 (4 % tall band at the bottom).
The topmost threshold line (overflow at max basinHeight) sits at TANK_TOP=48 px,
leaving 10 px clearance above the line. Same for the bottommost (outflow) line.

Color: #8a8a8a -> #6a6a6a (slightly darker so it reads at the bigger size).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:59:58 +02:00
znetsixe
a16f526964 chore(dashboardAPI): enforce min visual gap between threshold lines
User-visible problem: with the basin config dryRunThresholdPercent=2 (so
dryRunLevel ≈ outflowLevel) and highVolumeSafetyThresholdPercent=98 (so
highSafetyLevel ≈ overflowLevel), two pairs of threshold lines sat right
on top of each other in the tank visual, leaving no room between them for
their labels. The 'BELOW' fallback in the label algorithm couldn't fit
either, so labels ended up crossing lines.

Fix: enforce a minimum 28 px visual gap between adjacent threshold lines
inside the tank (≈3.7 % of the 760-tall reference frame, > LABEL_H + 2).
Lines closer than that get spread apart while preserving order. If the
stack would push the lowest line past the tank floor, the whole stack
shifts up to fit. Slight geometric distortion is accepted — the tank
visual conveys ordering and zone structure, not exact-scale level
measurement; numeric values are still rendered next to each line.

Result: at any basin geometry, labels sit cleanly above their line with
no overlap, no label-on-line collision, and no fallback to a 'stacked'
position that crosses its own line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:52:19 +02:00
znetsixe
8afc6b9779 chore(dashboardAPI): center tank via canvas scale constraint + bigger labels
Per Grafana Canvas docs, the correct way to make elements stay centered
and stretch with panel size is to set 'constraint: { horizontal: scale,
vertical: scale }' on every element AND use margin-style placement
(top + bottom + left + right, all as percentages of the panel) instead
of pixel-based 'top + left + width + height'.

This commit:
- Adds 'constraint: scale/scale' to every canvas element.
- Converts all placements to percentage margins. Hardcoded canvas
  geometry (tank, zones, threshold lines, header, footer) uses literal
  percentages; per-basin geometry (yp_*, ty_*, etc.) is precomputed in
  _templateVarsForNode and emitted as percent values from the substitution.
- Adds derived 'zb_*', 'yb_*', 'tyb_*' substitution vars for bottom
  margins of zones, lines, and labels respectively.
- Splits name/value labels left/right of tank centre with a visible gap
  between them (was touching) and bumps font size 11 -> 14 for readability.

Result: at any panel/viewport size the tank fills the card with equal
left/right margins (~2.5%) and equal top/bottom margins (~5.26%) for
rim/floor captions, no letterboxing or right-side padding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:22:11 +02:00
znetsixe
193f913eb1 chore(dashboardAPI): center tank vertically + handle floor-edge labels
Tank rectangle moved from top-aligned (top=20 in 760 frame) to vertically
centered (top=40, with 40 px top + 40 px bottom margins for the rim and
floor caption text). Header rim caption shifted to y=20, footer floor to
y=724, so both sit just outside the tank rect.

Label algorithm extended: when a label would normally go BELOW its line
but doing so would push it past the tank floor (which happens for very
small dryRunThresholdPercent — dryRunLevel sits right on outflowLevel,
both nearly at the basin floor), it falls back to stacking ABOVE the
previous label instead of extending into invisible space. This keeps
all 5 threshold labels inside the visible canvas area at the cost of a
slight visual overlap of the lowest label with its own line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:38:58 +02:00
znetsixe
41a20d4679 chore(dashboardAPI): center basin labels, position above/below lines
Threshold labels were sitting right on top of their lines (label center
at line_y - 8) and were right-aligned at the tank's right edge. They now:

- Sit clearly above the line (label bottom 6 px above) by default, or
  below the line (label top 6 px below) when an adjacent threshold is
  closer than 24 px (would crowd both labels above their lines). For
  the current basin config this puts overflowLevel + inflowLevel +
  dryRunLevel ABOVE their lines, and highSafety + outflowLevel BELOW.
- Are centered horizontally in the tank (name at left:115 width:95
  right-aligned, value at left:215 width:80 left-aligned) so the
  combined phrase "overflowLevel  3.22 m" reads as one centered string.

Value width 60 → 80 so 'mm'-formatted small-meter values don't wrap to
two lines. Footer floor moved to y:728 to keep clear of the BELOW labels
near the tank floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:32:45 +02:00
4 changed files with 455 additions and 74 deletions

View File

@@ -227,14 +227,15 @@
"root": {
"name": "Basin",
"type": "frame",
"placement": { "left": 0, "top": 0, "width": 400, "height": 760 },
"placement": { "left": 0, "top": 0, "right": 0, "bottom": 0 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "dark-green" } },
"elements": [
{
"name": "Zone Spill",
"type": "rectangle",
"placement": { "top": 20, "left": 10, "width": 380, "height": {{h_spill}} },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 6.32, "left": 2.5, "right": 2.5, "bottom": {{zb_spill}} },
"background": { "color": { "fixed": "rgba(229, 67, 67, 0.18)" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
@@ -242,7 +243,8 @@
{
"name": "Zone HighSafety",
"type": "rectangle",
"placement": { "top": {{y_overflow}}, "left": 10, "width": 380, "height": {{h_highSafety}} },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_overflow}}, "left": 2.5, "right": 2.5, "bottom": {{zb_highSafety}} },
"background": { "color": { "fixed": "rgba(242, 165, 67, 0.16)" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
@@ -250,7 +252,8 @@
{
"name": "Zone Operating",
"type": "rectangle",
"placement": { "top": {{y_highSafety}}, "left": 10, "width": 380, "height": {{h_operating}} },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_highSafety}}, "left": 2.5, "right": 2.5, "bottom": {{zb_operating}} },
"background": { "color": { "fixed": "rgba(95, 179, 122, 0.14)" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
@@ -258,7 +261,8 @@
{
"name": "Zone Dead",
"type": "rectangle",
"placement": { "top": {{y_outflow}}, "left": 10, "width": 380, "height": {{h_dead}} },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_outflow}}, "left": 2.5, "right": 2.5, "bottom": {{zb_dead}} },
"background": { "color": { "fixed": "rgba(128, 128, 128, 0.20)" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
@@ -266,7 +270,8 @@
{
"name": "Tank Outline",
"type": "rectangle",
"placement": { "top": 20, "left": 10, "width": 380, "height": 680 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 6.32, "left": 2.5, "right": 2.5, "bottom": 6.32 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "#8a8a8a" }, "width": 2 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
@@ -274,133 +279,150 @@
{
"name": "Line Overflow",
"type": "rectangle",
"placement": { "top": {{y_overflow}}, "left": 10, "width": 380, "height": 1 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_overflow}}, "left": 2.5, "right": 2.5, "bottom": {{yb_overflow}} },
"background": { "color": { "fixed": "#e54343" } },
"border": { "color": { "fixed": "#e54343" }, "width": 0 }
},
{
"name": "Line HighSafety",
"type": "rectangle",
"placement": { "top": {{y_highSafety}}, "left": 10, "width": 380, "height": 1 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_highSafety}}, "left": 2.5, "right": 2.5, "bottom": {{yb_highSafety}} },
"background": { "color": { "fixed": "#f2a543" } },
"border": { "color": { "fixed": "#f2a543" }, "width": 0 }
},
{
"name": "Line Inflow",
"type": "rectangle",
"placement": { "top": {{y_inflow}}, "left": 10, "width": 380, "height": 1 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_inflow}}, "left": 2.5, "right": 2.5, "bottom": {{yb_inflow}} },
"background": { "color": { "fixed": "#5fb37a" } },
"border": { "color": { "fixed": "#5fb37a" }, "width": 0 }
},
{
"name": "Line DryRun",
"type": "rectangle",
"placement": { "top": {{y_dryRun}}, "left": 10, "width": 380, "height": 1 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_dryRun}}, "left": 2.5, "right": 2.5, "bottom": {{yb_dryRun}} },
"background": { "color": { "fixed": "#5b9bd5" } },
"border": { "color": { "fixed": "#5b9bd5" }, "width": 0 }
},
{
"name": "Line Outflow",
"type": "rectangle",
"placement": { "top": {{y_outflow}}, "left": 10, "width": 380, "height": 1 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_outflow}}, "left": 2.5, "right": 2.5, "bottom": {{yb_outflow}} },
"background": { "color": { "fixed": "#bfbfbf" } },
"border": { "color": { "fixed": "#bfbfbf" }, "width": 0 }
},
{
"name": "Label Overflow Name",
"type": "text",
"placement": { "top": {{ty_overflow}}, "left": 180, "width": 140, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_overflow}}, "left": 15, "right": 53, "bottom": {{tyb_overflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 11, "align": "right", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Label HighSafety Name",
"type": "text",
"placement": { "top": {{ty_highSafety}}, "left": 180, "width": 140, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_highSafety}}, "left": 15, "right": 53, "bottom": {{tyb_highSafety}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "highSafety" }, "color": { "fixed": "#cf7e20" }, "size": 11, "align": "right", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "highSafety" }, "color": { "fixed": "#cf7e20" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Label Inflow Name",
"type": "text",
"placement": { "top": {{ty_inflow}}, "left": 180, "width": 140, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_inflow}}, "left": 15, "right": 53, "bottom": {{tyb_inflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 11, "align": "right", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Label DryRun Name",
"type": "text",
"placement": { "top": {{ty_dryRun}}, "left": 180, "width": 140, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_dryRun}}, "left": 15, "right": 53, "bottom": {{tyb_dryRun}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 11, "align": "right", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Label Outflow Name",
"type": "text",
"placement": { "top": {{ty_outflow}}, "left": 180, "width": 140, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_outflow}}, "left": 15, "right": 53, "bottom": {{tyb_outflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 11, "align": "right", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Value Overflow",
"type": "metric-value",
"placement": { "top": {{ty_overflow}}, "left": 323, "width": 65, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_overflow}}, "left": 53, "right": 12, "bottom": {{tyb_overflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 11, "align": "left", "valign": "middle" }
"config": { "text": { "mode": "field", "fixed": "", "field": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Value HighSafety",
"type": "metric-value",
"placement": { "top": {{ty_highSafety}}, "left": 323, "width": 65, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_highSafety}}, "left": 53, "right": 12, "bottom": {{tyb_highSafety}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "highVolumeSafetyLevel" }, "color": { "fixed": "#cf7e20" }, "size": 11, "align": "left", "valign": "middle" }
"config": { "text": { "mode": "field", "fixed": "", "field": "highVolumeSafetyLevel" }, "color": { "fixed": "#cf7e20" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Value Inflow",
"type": "metric-value",
"placement": { "top": {{ty_inflow}}, "left": 323, "width": 65, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_inflow}}, "left": 53, "right": 12, "bottom": {{tyb_inflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 11, "align": "left", "valign": "middle" }
"config": { "text": { "mode": "field", "fixed": "", "field": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Value DryRun",
"type": "metric-value",
"placement": { "top": {{ty_dryRun}}, "left": 323, "width": 65, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_dryRun}}, "left": 53, "right": 12, "bottom": {{tyb_dryRun}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 11, "align": "left", "valign": "middle" }
"config": { "text": { "mode": "field", "fixed": "", "field": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Value Outflow",
"type": "metric-value",
"placement": { "top": {{ty_outflow}}, "left": 323, "width": 65, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_outflow}}, "left": 53, "right": 12, "bottom": {{tyb_outflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 11, "align": "left", "valign": "middle" }
"config": { "text": { "mode": "field", "fixed": "", "field": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Header Rim",
"type": "text",
"placement": { "top": 2, "left": 10, "width": 380, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 1, "left": 2.5, "right": 2.5, "bottom": 95 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "rim ({{heightBasin}} m)" }, "color": { "fixed": "#8a8a8a" }, "size": 10, "align": "center", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "rim ({{heightBasin}} m)" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "center", "valign": "middle" }
},
{
"name": "Footer Floor",
"type": "text",
"placement": { "top": 702, "left": 10, "width": 380, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 95, "left": 2.5, "right": 2.5, "bottom": 1 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "floor (0.00 m)" }, "color": { "fixed": "#8a8a8a" }, "size": 10, "align": "center", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "floor (0.00 m)" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "center", "valign": "middle" }
}
]
}

View File

@@ -41,7 +41,20 @@ async function emitDashboardsFor(source, childSource, ctx, msg, trigger) {
? await source.resolveFolderUid()
: (source.config?.grafanaConnector?.folderUid || undefined);
// Resolve the InfluxDB datasource uid by querying the target Grafana, then
// rewrite every panel/target/variable on each dashboard. Templates ship a
// hardcoded uid that only matches the Grafana they were authored against;
// without this rewrite a fresh Grafana renders every panel as
// "Datasource <uid> not found". Failure is non-fatal: rewriteDatasourceUid
// is a no-op when uid is empty, so panels keep their template uid.
const datasourceUid = typeof source.resolveDatasourceUid === 'function'
? await source.resolveDatasourceUid()
: '';
for (const dash of dashboards) {
if (datasourceUid && typeof source.rewriteDatasourceUid === 'function') {
source.rewriteDatasourceUid(dash.dashboard, datasourceUid);
}
ctx.send({
...msg,
topic: 'create',

View File

@@ -189,42 +189,107 @@ class DashboardApi {
const dryRunLevel = outflowLevel * (1 + dryRunPct / 100);
const highSafetyLevel = overflowLevel * (highPct / 100);
// Canvas tank: rim at y=20px, floor at y=700px (680px tall). Must match
// hard-coded tank rectangle placement in config/pumpingStation.json
// (basin row is h:20 grid rows; canvas root frame is 400x760 px — taller
// than wide to match the card's aspect ratio so the tank fills the card
// vertically with no letterboxing).
const TANK_TOP = 20, TANK_BOT = 700, TANK_H = TANK_BOT - TANK_TOP;
// 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
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);
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);
// 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;
// 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);
}
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;
// 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);
}
const ty = Object.fromEntries(labels.map((l) => [l.id, +l.y.toFixed(2)]));
// 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),
@@ -232,16 +297,34 @@ class DashboardApi {
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,
// 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),
};
}
@@ -267,6 +350,11 @@ class DashboardApi {
return `${protocol}://${host}:${port}/api/folders`;
}
grafanaDatasourcesUrl() {
const { protocol, host, port } = this.config.grafanaConnector;
return `${protocol}://${host}:${port}/api/datasources`;
}
_grafanaJsonHeaders() {
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
const token = this.config.grafanaConnector.bearerToken;
@@ -332,6 +420,90 @@ class DashboardApi {
return '';
}
// Resolve the target Grafana InfluxDB datasource uid at push time. Templates
// ship with a hardcoded uid baked into every panel; that uid only matches the
// Grafana instance the templates were authored against. Any other Grafana
// (fresh laptop, VPS, rebuilt instance) renders the panels as
// "Datasource <uid> not found". Resolution is done once per process and
// cached.
//
// Degradation contract: any failure (no fetch, network error, non-OK
// response, no influxdb datasource present) returns '' and the caller leaves
// the template's baked-in uid alone. Worst-case behavior is unchanged from
// before this resolver existed.
async resolveDatasourceUid({ fetchImpl = globalThis.fetch } = {}) {
if (this._resolvedDatasourceUid) return this._resolvedDatasourceUid;
if (typeof fetchImpl !== 'function') {
this.logger.warn('resolveDatasourceUid: no fetch implementation available; leaving template uid intact');
return '';
}
try {
const uid = await this._lookupInfluxDatasource(fetchImpl);
if (uid) {
this._resolvedDatasourceUid = uid;
return uid;
}
} catch (err) {
this.logger.warn(`resolveDatasourceUid failed (${err?.message || err}); leaving template uid intact`);
}
return '';
}
async _lookupInfluxDatasource(fetchImpl) {
const url = this.grafanaDatasourcesUrl();
const headers = this._grafanaJsonHeaders();
const res = await fetchImpl(url, { method: 'GET', headers });
if (!res?.ok) {
this.logger.warn(`resolveDatasourceUid: GET /api/datasources -> ${res?.status}`);
return '';
}
const list = await res.json();
const match = Array.isArray(list) && list.find((d) => String(d?.type || '').toLowerCase() === 'influxdb');
if (match?.uid) {
this.logger.info({ event: 'datasource-resolved', outcome: 'found', name: match.name, uid: match.uid });
return match.uid;
}
this.logger.warn('resolveDatasourceUid: no influxdb datasource on target Grafana');
return '';
}
// Rewrite every influxdb datasource.uid on a dashboard (panels, nested row
// panels, panel.targets, templating variables) to `uid`. No-op for any
// datasource whose type isn't 'influxdb' (e.g. the '-- Grafana --' annotation
// datasource) or whose uid is a template variable reference (e.g.
// '${datasource}'). No-op when `uid` is falsy.
rewriteDatasourceUid(dashboard, uid) {
if (!uid || !dashboard) return;
const visit = (panels) => {
if (!Array.isArray(panels)) return;
for (const panel of panels) {
if (panel?.datasource && String(panel.datasource.type || '').toLowerCase() === 'influxdb'
&& typeof panel.datasource.uid === 'string' && !panel.datasource.uid.startsWith('$')) {
panel.datasource.uid = uid;
}
if (Array.isArray(panel?.targets)) {
for (const t of panel.targets) {
if (t?.datasource && String(t.datasource.type || '').toLowerCase() === 'influxdb'
&& typeof t.datasource.uid === 'string' && !t.datasource.uid.startsWith('$')) {
t.datasource.uid = uid;
}
}
}
visit(panel?.panels);
}
};
visit(dashboard.panels);
const tplList = dashboard?.templating?.list;
if (Array.isArray(tplList)) {
for (const v of tplList) {
if (v?.datasource && String(v.datasource.type || '').toLowerCase() === 'influxdb'
&& typeof v.datasource.uid === 'string' && !v.datasource.uid.startsWith('$')) {
v.datasource.uid = uid;
}
}
}
}
buildDashboard({ nodeConfig, positionVsParent }) {
const softwareType =
nodeConfig?.functionality?.softwareType ||

View File

@@ -0,0 +1,174 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
const { registerChild } = require('../../src/commands/handlers.js');
function makeFetch(routes) {
const calls = [];
const fetchImpl = async (url, opts = {}) => {
const method = opts.method || 'GET';
const { pathname } = new URL(url);
calls.push({ method, pathname, body: opts.body });
const r = routes[`${method} ${pathname}`];
if (!r) return { ok: false, status: 404, json: async () => ({ message: 'not found' }) };
if (typeof r === 'function') return r();
return { ok: r.ok ?? true, status: r.status ?? 200, json: async () => r.body };
};
fetchImpl.calls = calls;
return fetchImpl;
}
function api(grafanaConnector = {}) {
return new DashboardApi({ grafanaConnector });
}
test('resolveDatasourceUid returns the first influxdb datasource uid', async () => {
const a = api();
const fetchImpl = makeFetch({
'GET /api/datasources': {
body: [
{ type: 'prometheus', uid: 'p1' },
{ type: 'influxdb', uid: 'dfmpjg9jjvym8b', name: 'influxdb' },
{ type: 'influxdb', uid: 'second-one' },
],
},
});
const uid = await a.resolveDatasourceUid({ fetchImpl });
assert.equal(uid, 'dfmpjg9jjvym8b');
});
test('resolveDatasourceUid is cached → second call makes no further fetch', async () => {
const a = api();
const fetchImpl = makeFetch({
'GET /api/datasources': { body: [{ type: 'influxdb', uid: 'u1' }] },
});
await a.resolveDatasourceUid({ fetchImpl });
await a.resolveDatasourceUid({ fetchImpl });
assert.equal(fetchImpl.calls.length, 1);
});
test('resolveDatasourceUid returns empty string when no influxdb datasource exists', async () => {
const a = api();
const fetchImpl = makeFetch({
'GET /api/datasources': { body: [{ type: 'prometheus', uid: 'p1' }] },
});
const uid = await a.resolveDatasourceUid({ fetchImpl });
assert.equal(uid, '');
});
test('resolveDatasourceUid: fetch throws → returns empty string (template uid preserved)', async () => {
const a = api();
const fetchImpl = async () => { throw new Error('ECONNREFUSED'); };
const uid = await a.resolveDatasourceUid({ fetchImpl });
assert.equal(uid, '');
});
test('resolveDatasourceUid: no fetch available → returns empty string', async () => {
const a = api();
const uid = await a.resolveDatasourceUid({ fetchImpl: null });
assert.equal(uid, '');
});
test('rewriteDatasourceUid: rewrites panel.datasource.uid for influxdb only', () => {
const a = api();
const dashboard = {
panels: [
{ datasource: { type: 'influxdb', uid: 'OLD' } },
{ datasource: { type: 'grafana', uid: '-- Grafana --' } },
],
};
a.rewriteDatasourceUid(dashboard, 'NEW');
assert.equal(dashboard.panels[0].datasource.uid, 'NEW');
assert.equal(dashboard.panels[1].datasource.uid, '-- Grafana --');
});
test('rewriteDatasourceUid: rewrites panel.targets[].datasource.uid', () => {
const a = api();
const dashboard = {
panels: [
{
datasource: { type: 'influxdb', uid: 'OLD' },
targets: [
{ datasource: { type: 'influxdb', uid: 'OLD' }, query: 'a' },
{ datasource: { type: 'influxdb', uid: 'OLD' }, query: 'b' },
],
},
],
};
a.rewriteDatasourceUid(dashboard, 'NEW');
for (const t of dashboard.panels[0].targets) assert.equal(t.datasource.uid, 'NEW');
});
test('rewriteDatasourceUid: descends into nested row panels', () => {
const a = api();
const dashboard = {
panels: [
{
type: 'row',
panels: [
{ datasource: { type: 'influxdb', uid: 'OLD' } },
],
},
],
};
a.rewriteDatasourceUid(dashboard, 'NEW');
assert.equal(dashboard.panels[0].panels[0].datasource.uid, 'NEW');
});
test('rewriteDatasourceUid: rewrites templating.list[] influxdb variables', () => {
const a = api();
const dashboard = {
panels: [],
templating: {
list: [
{ type: 'query', datasource: { type: 'influxdb', uid: 'OLD' } },
{ type: 'constant', datasource: { type: 'prometheus', uid: 'OLD' } },
],
},
};
a.rewriteDatasourceUid(dashboard, 'NEW');
assert.equal(dashboard.templating.list[0].datasource.uid, 'NEW');
assert.equal(dashboard.templating.list[1].datasource.uid, 'OLD');
});
test('rewriteDatasourceUid: leaves template-variable references alone (${datasource})', () => {
const a = api();
const dashboard = {
panels: [{ datasource: { type: 'influxdb', uid: '${datasource}' } }],
};
a.rewriteDatasourceUid(dashboard, 'NEW');
assert.equal(dashboard.panels[0].datasource.uid, '${datasource}');
});
test('rewriteDatasourceUid: no-op when uid is falsy (preserves template)', () => {
const a = api();
const dashboard = { panels: [{ datasource: { type: 'influxdb', uid: 'KEEP' } }] };
a.rewriteDatasourceUid(dashboard, '');
assert.equal(dashboard.panels[0].datasource.uid, 'KEEP');
});
test('emit path rewrites every upsert dashboard with the resolved datasource uid', async () => {
const a = api({ folderTitle: 'EVOLV' });
a.resolveFolderUid = async () => 'fld';
a.resolveDatasourceUid = async () => 'resolved-ds-uid';
const childSource = {
config: { general: { id: 'm1', name: 'Level' }, functionality: { softwareType: 'measurement' } },
};
const sent = [];
const ctx = { node: { id: 'dapi' }, send: (m) => sent.push(m) };
await registerChild(a, { payload: childSource }, ctx);
assert.ok(sent.length >= 1);
for (const m of sent) {
const panels = m.payload?.dashboard?.panels || [];
for (const p of panels) {
if (p?.datasource?.type === 'influxdb') {
assert.equal(p.datasource.uid, 'resolved-ds-uid');
}
}
}
});