3 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
4 changed files with 318 additions and 14 deletions

View File

@@ -235,7 +235,7 @@
"name": "Zone Spill",
"type": "rectangle",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 5.26, "left": 2.5, "right": 2.5, "bottom": {{zb_spill}} },
"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": "" } }
@@ -271,7 +271,7 @@
"name": "Tank Outline",
"type": "rectangle",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 5.26, "left": 2.5, "right": 2.5, "bottom": 5.27 },
"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": "" } }
@@ -410,19 +410,19 @@
"name": "Header Rim",
"type": "text",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 2.63, "left": 2.5, "right": 2.5, "bottom": 95.26 },
"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",
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 95.26, "left": 2.5, "right": 2.5, "bottom": 2.63 },
"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

@@ -194,10 +194,13 @@ class DashboardApi {
// 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=40px (5.26%), floor at y=720px (94.74%),
// centred vertically with 40px top/bottom margins for rim/floor labels.
// 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 = 40, TANK_BOT = 720, TANK_H = TANK_BOT - TANK_TOP;
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);
@@ -205,11 +208,36 @@ class DashboardApi {
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);
// 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
@@ -322,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;
@@ -387,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');
}
}
}
});