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'); + } + } + } +});