UNPKG

openclaw-grafana-lens

Version:

OpenClaw plugin that gives AI agents full Grafana access — 18 composable tools for PromQL/LogQL/TraceQL queries, dashboard creation, alerting, SRE investigation, security monitoring, data collection pipeline management via Grafana Alloy (29 recipes), and

199 lines (198 loc) 7.5 kB
/** * Alloy HTTP Client * * Communicates with Grafana Alloy's HTTP API for pipeline management: * - Health checks (/-/healthy, /-/ready) * - Config reload (/-/reload) * - Component introspection (/api/v0/component/{id}) * * Follows the same pattern as grafana-client.ts: class with typed methods, * fetch-based HTTP calls, actionable error messages. * * Alloy's HTTP API runs on a configurable port (default: 12345). * Key behaviors: * - /-/healthy returns 500 with unhealthy component names if any are unhealthy * - /-/reload returns 400 with error message if config reload fails * - A failed reload keeps the previous running config (safe by default) */ const DEFAULT_TIMEOUT = 5_000; export class AlloyClient { url; timeout; constructor(opts) { this.url = opts.url.replace(/\/+$/, ""); this.timeout = opts.timeout ?? DEFAULT_TIMEOUT; } /** Base URL for display in error messages. */ get baseUrl() { return this.url; } // ── Health Checks ─────────────────────────────────────────────────── /** * Check if Alloy is healthy (all components running). * Returns { ok: true } if healthy, or { ok: false, unhealthyComponents } if not. * Returns { ok: false } with error if Alloy is unreachable. */ async healthy() { try { const res = await this.fetchWithTimeout(`${this.url}/-/healthy`); if (res.ok) return { ok: true }; // Alloy returns "unhealthy components: comp1, comp2\n" (comma-separated, single line) const body = await res.text(); const stripped = body.replace(/^unhealthy components:\s*/i, "").trim(); const components = stripped .split(",") .map((c) => c.trim()) .filter((c) => c.length > 0); return { ok: false, unhealthyComponents: components }; } catch (err) { return { ok: false, error: this.formatError(err) }; } } /** * Check if Alloy is ready (initial config load complete). */ async ready() { try { const res = await this.fetchWithTimeout(`${this.url}/-/ready`); return res.ok; } catch { return false; } } // ── Config Management ─────────────────────────────────────────────── /** * Trigger config reload. Alloy re-reads all .alloy files from its config directory. * * On success: returns { ok: true }. * On failure: returns { ok: false, error } — Alloy keeps previous config running. */ async reload() { try { const res = await this.fetchWithTimeout(`${this.url}/-/reload`, { method: "POST", }); if (res.ok) return { ok: true }; const body = await res.text(); return { ok: false, error: body || `Reload failed with status ${res.status}`, }; } catch (err) { return { ok: false, error: this.formatError(err) }; } } // ── Component Introspection ───────────────────────────────────────── /** * List all running components. */ async listComponents() { const res = await this.fetchWithTimeout(`${this.url}/api/v0/web/components`); if (!res.ok) { throw new Error(`Failed to list Alloy components: ${res.status} ${res.statusText}`); } const data = (await res.json()); return data; } /** * Get a specific component's status by ID. * Returns null if the component doesn't exist. */ async getComponent(id) { try { const encoded = encodeURIComponent(id); const res = await this.fetchWithTimeout(`${this.url}/api/v0/web/components/${encoded}`); if (res.status === 404) return null; if (!res.ok) { throw new Error(`Failed to get component ${id}: ${res.status}`); } return (await res.json()); } catch (err) { // Component not found or API error if (err instanceof Error && err.message.includes("404")) return null; throw err; } } /** * Check health of specific components by their IDs. * Fetches all components in a single request, then does local lookups. */ async checkPipelineHealth(componentIds) { const result = { healthy: [], unhealthy: [], missing: [], }; // Single batch fetch instead of N individual requests let allComponents; try { allComponents = await this.listComponents(); } catch { // If we can't list components, treat all as unhealthy for (const id of componentIds) { result.unhealthy.push({ id, message: "Failed to list Alloy components" }); } return result; } const byId = new Map(allComponents.map((c) => [c.localID, c])); for (const id of componentIds) { const comp = byId.get(id); if (!comp) { result.missing.push(id); } else if (comp.health.state === "healthy") { result.healthy.push(id); } else { result.unhealthy.push({ id, message: comp.health.message }); } } return result; } // ── Internal ──────────────────────────────────────────────────────── async fetchWithTimeout(url, init) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeout); try { return await fetch(url, { ...init, signal: controller.signal, headers: { ...init?.headers, Accept: "application/json", }, }); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { throw new Error(`Alloy request timed out after ${this.timeout}ms — is Alloy running at ${this.url}?`); } throw new Error(`Alloy request failed: ${this.formatError(err)} — check that Alloy is running at ${this.url}`); } finally { clearTimeout(timer); } } formatError(err) { if (err instanceof Error) { // Improve common error messages if (err.message.includes("ECONNREFUSED")) { return `Connection refused at ${this.url} — Alloy may not be running`; } if (err.message.includes("ETIMEDOUT") || err.message.includes("AbortError")) { return `Request timed out — Alloy may be overloaded or unreachable at ${this.url}`; } return err.message; } return String(err); } }