Nineteen modules gain a broker-bound runtime container that serves the module's tools under its own scoped account: bazarr, gitea, grafana, home-assistant, icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi, photos, portainer, qbittorrent, searxng, tautulli, verdaccio. Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env fallbacks, so URL and credentials come from `settings set`, with the URL defaulting to the server on the node. nextcloud and mailu also mount the docker socket for their exec-based tools. Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token, the runtime reads the merged config and serves grafana's tools under the scoped account, with nothing in the manifest. Two gaps this surfaced are filed as hq issues 008 (a provider runtime's seal key) and 009 (a settings change does not restart a container runtime). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
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 0044). 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 0051): { 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,
|
|
}));
|
|
}
|
|
}
|