121 lines
4.4 KiB
TypeScript
121 lines
4.4 KiB
TypeScript
// 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<string, string>;
|
|
activeAt?: string;
|
|
}
|
|
|
|
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
|
|
function meshConfig(file?: string): Record<string, string> {
|
|
if (!file) return {};
|
|
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
|
catch { return {}; }
|
|
}
|
|
|
|
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 a service-account/API token
|
|
* (MESH_GRAFANA_TOKEN, sent as Bearer) when present, else HTTP basic with the admin password the
|
|
* module keeps as its own secret (MESH_GRAFANA_PASSWORD, user MESH_GRAFANA_USER, default admin).
|
|
* 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"}`;
|
|
const token = cfg.token ?? env.MESH_GRAFANA_TOKEN;
|
|
if (token) return new GrafanaClient(url, `Bearer ${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")}`);
|
|
}
|
|
throw new Error("no Grafana auth — set MESH_GRAFANA_TOKEN or MESH_GRAFANA_PASSWORD");
|
|
}
|
|
|
|
private async get(path: string): Promise<any> {
|
|
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<GrafanaHealth> {
|
|
const h = await this.get("/api/health");
|
|
return { database: h.database ?? "unknown", version: h.version ?? "unknown", commit: h.commit ?? "unknown" };
|
|
}
|
|
|
|
async listDatasources(): Promise<GrafanaDatasource[]> {
|
|
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<GrafanaDashboard[]> {
|
|
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<GrafanaAlert[]> {
|
|
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,
|
|
}));
|
|
}
|
|
}
|