// Grafana's API client — grafana's own code, living in the module (novox/hq ADR 0039). Ported from // the shared hal sdk, where a change here rebuilt everything; here it rebuilds only grafana. Both // this module's tools and its events entrypoint import it, and nothing outside grafana does. import { readFileSync } from "node:fs"; export interface GrafanaHealth { database: string; version: string; commit: string; } export interface GrafanaDatasource { id: number; uid: string; name: string; type: string; url: string; isDefault: boolean; database?: string; } export interface GrafanaDashboard { uid: string; title: string; url: string; tags: string[]; folderTitle?: string; } export interface GrafanaAlert { /** The rule name (labels.alertname), the stable identity a firing alert is diffed on. */ name: string; /** Grafana unified-alerting state: "Normal" | "Pending" | "Alerting". */ state: string; labels: Record; activeAt?: string; } /** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ function meshConfig(file?: string): Record { if (!file) return {}; try { return JSON.parse(readFileSync(file, "utf8")) as Record; } catch { return {}; } } /** * A password, token or API key is the vault's, never a setting (novox/hq issue 342): a value for one of * them in the settings file is ignored, and that is said once — by its key, never with its value. */ const saidIgnored = new Set(); function ignoreSecretSetting(cfg: Record, key: string): void { if (cfg[key] === undefined || saidIgnored.has(key)) return; saidIgnored.add(key); console.error(`the setting ${key} is ignored: it comes from the vault`); } export class GrafanaClient { readonly baseUrl: string; private readonly authHeader: string; constructor(url: string, authHeader: string) { this.baseUrl = url.replace(/\/$/, ""); this.authHeader = authHeader; } /** * Build from the module's resolved environment. Auth is HTTP basic with the vault's admin password, * which the runtime config carries as `password` (kept protected, so no setting replaces it) or * MESH_GRAFANA_PASSWORD (user MESH_GRAFANA_USER, default admin); else a service-account/API token from * the environment (MESH_GRAFANA_TOKEN, sent as Bearer). A `token` in the settings file is never used: a * credential is the vault's, never a setting (hq issue 342). Throws when neither is configured — the * module then contributes nothing rather than failing. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): GrafanaClient { const cfg = meshConfig(env.MESH_GRAFANA_CONFIG_FILE); const url = cfg.url ?? env.MESH_GRAFANA_URL ?? `http://127.0.0.1:${env.GRAFANA_PORT ?? "3000"}`; ignoreSecretSetting(cfg, "token"); const password = cfg.password ?? env.MESH_GRAFANA_PASSWORD; if (password) { const user = cfg.user ?? env.MESH_GRAFANA_USER ?? "admin"; return new GrafanaClient(url, `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`); } const token = env.MESH_GRAFANA_TOKEN; if (token) return new GrafanaClient(url, `Bearer ${token}`); throw new Error("no Grafana auth — set MESH_GRAFANA_TOKEN or MESH_GRAFANA_PASSWORD"); } private async get(path: string): Promise { const res = await fetch(`${this.baseUrl}${path}`, { headers: { Authorization: this.authHeader, Accept: "application/json" }, }); if (!res.ok) throw new Error(`Grafana API ${path}: ${res.status} ${await res.text()}`); return res.json(); } async health(): Promise { const h = await this.get("/api/health"); return { database: h.database ?? "unknown", version: h.version ?? "unknown", commit: h.commit ?? "unknown" }; } async listDatasources(): Promise { const arr = (await this.get("/api/datasources")) as any[]; return arr.map((d) => ({ id: d.id, uid: d.uid, name: d.name, type: d.type, url: d.url, isDefault: !!d.isDefault, database: d.database || undefined, })); } async listDashboards(query?: string): Promise { const params = new URLSearchParams({ type: "dash-db" }); if (query) params.set("query", query); const arr = (await this.get(`/api/search?${params.toString()}`)) as any[]; return arr.map((d) => ({ uid: d.uid, title: d.title, url: d.url, tags: d.tags ?? [], folderTitle: d.folderTitle || undefined, })); } /** * Active alert instances from unified alerting's Prometheus-compatible surface. Grafana without * alerting configured answers this with an empty set (or a 404, surfaced by get) — callers treat * "no alerts" and "no alerting" alike. */ async listAlerts(): Promise { const data = (await this.get("/api/prometheus/grafana/api/v1/alerts")).data ?? {}; const alerts = (data.alerts ?? []) as any[]; return alerts.map((a) => ({ name: a.labels?.alertname ?? "unknown", state: a.state ?? "unknown", labels: a.labels ?? {}, activeAt: a.activeAt || undefined, })); } }