feat(dashboardAPI): resolve Grafana folder by name (fixes stale folderUid 400s)
A pinned folderUid goes stale whenever Grafana is rebuilt — the same-named folder returns with a fresh uid and every dashboard upsert then 400s "folder not found", silently dropping all generated dashboards. Add a folderTitle config field: when set, resolveFolderUid() looks the folder up by name (GET /api/folders), creates it if absent (POST /api/folders), caches the uid for the process, and falls back to the configured folderUid on any failure (never worse than the pinned behavior). The emit handlers (registerChild/regenerateDashboard/emitDashboardsFor) are now async and await the resolution. folderUid retained as an explicit override/fallback. Locked by slice48-folder-resolve-by-name; existing emit tests made async. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -86,6 +86,12 @@ class DashboardApi {
|
||||
host: config?.grafanaConnector?.host || 'localhost',
|
||||
port: Number(config?.grafanaConnector?.port || 3000),
|
||||
bearerToken: config?.grafanaConnector?.bearerToken || '',
|
||||
// folderTitle is the durable way to target a folder: Grafana folder
|
||||
// uids change whenever the instance is rebuilt, so a pinned folderUid
|
||||
// goes stale (every upsert then 400s "folder not found"). When set, the
|
||||
// uid is resolved (and the folder created if absent) by name at emit
|
||||
// time. folderUid stays supported as an explicit override / fallback.
|
||||
folderTitle: config?.grafanaConnector?.folderTitle || '',
|
||||
folderUid: config?.grafanaConnector?.folderUid || '',
|
||||
},
|
||||
defaultBucket: config?.defaultBucket || '',
|
||||
@@ -158,6 +164,76 @@ class DashboardApi {
|
||||
return `${protocol}://${host}:${port}/api/dashboards/db`;
|
||||
}
|
||||
|
||||
grafanaFoldersUrl() {
|
||||
const { protocol, host, port } = this.config.grafanaConnector;
|
||||
return `${protocol}://${host}:${port}/api/folders`;
|
||||
}
|
||||
|
||||
_grafanaJsonHeaders() {
|
||||
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
|
||||
const token = this.config.grafanaConnector.bearerToken;
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Resolve the target Grafana folder uid by NAME, creating the folder if it
|
||||
// doesn't exist. This is the durable alternative to a pinned folderUid, which
|
||||
// goes stale on every Grafana rebuild (the new instance hands the same-named
|
||||
// folder a fresh uid, and every dashboard upsert then 400s "folder not
|
||||
// found"). Resolution is done once per process and cached.
|
||||
//
|
||||
// Degradation contract: any failure (no fetch, network error, non-OK
|
||||
// response) logs a warning and falls back to the configured folderUid, so the
|
||||
// node is never worse off than the pinned-uid behavior it replaces.
|
||||
async resolveFolderUid({ fetchImpl = globalThis.fetch } = {}) {
|
||||
const gc = this.config.grafanaConnector;
|
||||
const title = String(gc.folderTitle || '').trim();
|
||||
// No title configured → legacy behavior: use the explicit uid (may be '').
|
||||
if (!title) return gc.folderUid || '';
|
||||
if (this._resolvedFolderUid) return this._resolvedFolderUid;
|
||||
if (typeof fetchImpl !== 'function') {
|
||||
this.logger.warn('resolveFolderUid: no fetch implementation available; using configured folderUid');
|
||||
return gc.folderUid || '';
|
||||
}
|
||||
try {
|
||||
const uid = await this._lookupOrCreateFolder(title, fetchImpl);
|
||||
if (uid) {
|
||||
this._resolvedFolderUid = uid;
|
||||
return uid;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`resolveFolderUid failed (${err?.message || err}); using configured folderUid`);
|
||||
}
|
||||
return gc.folderUid || '';
|
||||
}
|
||||
|
||||
async _lookupOrCreateFolder(title, fetchImpl) {
|
||||
const url = this.grafanaFoldersUrl();
|
||||
const headers = this._grafanaJsonHeaders();
|
||||
|
||||
const listRes = await fetchImpl(url, { method: 'GET', headers });
|
||||
if (listRes?.ok) {
|
||||
const folders = await listRes.json();
|
||||
const match = Array.isArray(folders)
|
||||
&& folders.find((f) => String(f?.title || '').trim().toLowerCase() === title.toLowerCase());
|
||||
if (match?.uid) {
|
||||
this.logger.info({ event: 'folder-resolved', outcome: 'found', title, uid: match.uid });
|
||||
return match.uid;
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`resolveFolderUid: GET /api/folders -> ${listRes?.status}`);
|
||||
}
|
||||
|
||||
const createRes = await fetchImpl(url, { method: 'POST', headers, body: JSON.stringify({ title }) });
|
||||
if (createRes?.ok) {
|
||||
const created = await createRes.json();
|
||||
this.logger.info({ event: 'folder-resolved', outcome: 'created', title, uid: created?.uid });
|
||||
return created?.uid || '';
|
||||
}
|
||||
this.logger.warn(`resolveFolderUid: POST /api/folders -> ${createRes?.status}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
buildDashboard({ nodeConfig, positionVsParent }) {
|
||||
const softwareType =
|
||||
nodeConfig?.functionality?.softwareType ||
|
||||
|
||||
Reference in New Issue
Block a user