From de957cb971f0ed8b2d27449275dda951a7c78f66 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Thu, 28 May 2026 18:33:22 +0200 Subject: [PATCH] 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 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) --- src/commands/handlers.js | 13 ++ src/specificClass.js | 89 +++++++++ .../slice49-datasource-resolve.basic.test.js | 174 ++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 test/basic/slice49-datasource-resolve.basic.test.js diff --git a/src/commands/handlers.js b/src/commands/handlers.js index 29f57a8..192fca8 100644 --- a/src/commands/handlers.js +++ b/src/commands/handlers.js @@ -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 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', diff --git a/src/specificClass.js b/src/specificClass.js index edfc434..a26b095 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -350,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; @@ -415,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 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 || diff --git a/test/basic/slice49-datasource-resolve.basic.test.js b/test/basic/slice49-datasource-resolve.basic.test.js new file mode 100644 index 0000000..4e9803f --- /dev/null +++ b/test/basic/slice49-datasource-resolve.basic.test.js @@ -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'); + } + } + } +});