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

679 lines (678 loc) 29.2 kB
/** * Grafana HTTP client — self-contained, no external dependencies. * * Wraps Grafana's REST API for all operations grafana-lens needs: * - Dashboards: create, search, get, render, snapshot * - Queries: PromQL instant/range via datasource proxy * - Alerting: create/list/delete alert rules (Unified Alerting) * - Annotations: create/query event markers * - Datasources: list, discover metrics/labels * - Folders: create/list for organization */ export class GrafanaClient { baseUrl; headers; url; constructor(opts) { this.baseUrl = opts.url; this.url = opts.url; this.headers = { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json", ...(opts.orgId ? { "X-Grafana-Org-Id": String(opts.orgId) } : {}), }; } /** Public getter for the instance URL (used by GrafanaClientRegistry). */ getUrl() { return this.url; } async fetchWithTimeout(url, init, timeoutMs = 30_000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...init, signal: controller.signal }); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { throw new Error(`Grafana request timed out after ${timeoutMs}ms — is Grafana running at ${this.url}?`); } const msg = err instanceof Error ? err.message : String(err); throw new Error(`Grafana request failed: ${msg}`); } finally { clearTimeout(timer); } } /** * Create or update a dashboard. * Maps to POST /api/dashboards/db — the same endpoint mcp-grafana's * update_dashboard tool calls under the hood. */ async createDashboard(req) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/dashboards/db`, { method: "POST", headers: this.headers, body: JSON.stringify(req), }); if (!res.ok) { const body = await res.text(); throw this.classifyError("create dashboard", res.status, body); } return (await res.json()); } /** * Search for dashboards by query string with optional filters. */ async searchDashboards(query, opts) { const params = new URLSearchParams({ query, type: "dash-db" }); if (opts?.tags) { for (const tag of opts.tags) params.append("tag", tag); } if (opts?.starred) params.set("starred", "true"); if (opts?.sort) params.set("sort", opts.sort); if (opts?.limit) params.set("limit", String(opts.limit)); const res = await this.fetchWithTimeout(`${this.baseUrl}/api/search?${params}`, { headers: this.headers, }); if (!res.ok) { const body = await res.text(); throw this.classifyError("search dashboards", res.status, body); } return (await res.json()); } /** * Get a dashboard by UID. */ async getDashboard(uid) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/dashboards/uid/${uid}`, { headers: this.headers, }); if (!res.ok) { const body = await res.text(); throw this.classifyError(`get dashboard by uid ${uid}`, res.status, body); } return (await res.json()); } /** * Render a panel as PNG image. * Requires the Grafana Image Renderer plugin to be installed. * * Uses GET /render/d/{uid}?viewPanel={panelId}&kiosk=true * (verified from mcp-grafana tools/rendering.go) */ async renderPanel(dashboardUid, panelId, opts) { const params = new URLSearchParams({ viewPanel: String(panelId), kiosk: "true", width: String(opts?.width ?? 1000), height: String(opts?.height ?? 500), from: opts?.from ?? "now-6h", to: opts?.to ?? "now", theme: opts?.theme ?? "dark", scale: String(opts?.scale ?? 1), }); const res = await this.fetchWithTimeout(`${this.baseUrl}/render/d/${dashboardUid}?${params}`, { headers: this.headers }, 60_000); if (!res.ok) { const body = await res.text(); throw this.classifyRenderError(dashboardUid, panelId, res.status, body); } return res.arrayBuffer(); } /** * Create a dashboard snapshot. * Snapshots freeze the dashboard state at a point in time and provide * a shareable URL that works without authentication. */ async createSnapshot(dashboard, opts) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/snapshots`, { method: "POST", headers: this.headers, body: JSON.stringify({ dashboard, name: opts?.name, expires: opts?.expires, }), }); if (!res.ok) { const body = await res.text(); throw this.classifyError("create snapshot", res.status, body); } return (await res.json()); } /** Health check — validates the API key works. */ async healthCheck() { try { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/health`, { headers: this.headers, }, 10_000); return res.ok; } catch { return false; } } /** Returns the full URL for a dashboard by UID. */ dashboardUrl(uid) { return `${this.baseUrl}/d/${uid}`; } // ── Datasource methods ────────────────────────────────────────────── /** List all configured datasources. */ async listDatasources() { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/datasources`, { headers: this.headers, }); if (!res.ok) { const body = await res.text(); throw this.classifyError("list datasources", res.status, body); } return (await res.json()); } // ── Prometheus query methods ──────────────────────────────────────── /** Run a PromQL instant query against a Prometheus datasource. */ async queryPrometheus(dsUid, expr, time) { const params = new URLSearchParams({ query: expr }); if (time) params.set("time", parseDateMathToSeconds(time)); const res = await this.fetchWithTimeout(`${this.baseUrl}/api/datasources/proxy/uid/${dsUid}/api/v1/query?${params}`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError("query prometheus", res.status, body); } return (await res.json()); } /** Run a PromQL range query against a Prometheus datasource. */ async queryPrometheusRange(dsUid, expr, start, end, step) { const params = new URLSearchParams({ query: expr, start: parseDateMathToSeconds(start), end: parseDateMathToSeconds(end), step }); const res = await this.fetchWithTimeout(`${this.baseUrl}/api/datasources/proxy/uid/${dsUid}/api/v1/query_range?${params}`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError("query prometheus range", res.status, body); } return (await res.json()); } /** List available metric names from a Prometheus datasource. */ async listMetricNames(dsUid, opts) { const params = new URLSearchParams(); if (opts?.match) params.append("match[]", opts.match); const qs = params.toString(); const res = await this.fetchWithTimeout(`${this.baseUrl}/api/datasources/proxy/uid/${dsUid}/api/v1/label/__name__/values${qs ? `?${qs}` : ""}`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError("list metric names", res.status, body); } const data = (await res.json()); return data.data; } /** List values for a specific label from a Prometheus datasource. */ async listLabelValues(dsUid, label) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/datasources/proxy/uid/${dsUid}/api/v1/label/${encodeURIComponent(label)}/values`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError(`list label values for '${label}'`, res.status, body); } const data = (await res.json()); return data.data; } /** Get metric metadata (type, help, unit) from a Prometheus datasource. */ async getMetricMetadata(dsUid, opts) { const params = new URLSearchParams(); if (opts?.limit) params.set("limit", String(opts.limit)); if (opts?.metric) params.set("metric", opts.metric); const qs = params.toString(); const res = await this.fetchWithTimeout(`${this.baseUrl}/api/datasources/proxy/uid/${dsUid}/api/v1/metadata${qs ? `?${qs}` : ""}`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError("get metric metadata", res.status, body); } const data = (await res.json()); return data.data; } // ── Loki query methods ─────────────────────────────────────────────── /** Run a LogQL instant query against a Loki datasource. */ async queryLoki(dsUid, expr, opts) { const params = new URLSearchParams({ query: expr }); if (opts?.time) params.set("time", parseDateMathToNs(opts.time)); if (opts?.limit && opts.limit > 0) params.set("limit", String(opts.limit)); if (opts?.direction) params.set("direction", opts.direction); const res = await this.fetchWithTimeout(`${this.baseUrl}/api/datasources/proxy/uid/${dsUid}/loki/api/v1/query?${params}`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError("query loki", res.status, body); } return (await res.json()); } /** Run a LogQL range query against a Loki datasource. */ async queryLokiRange(dsUid, expr, start, end, opts) { const params = new URLSearchParams({ query: expr, start: parseDateMathToNs(start), end: parseDateMathToNs(end) }); if (opts?.step) params.set("step", opts.step); if (opts?.limit && opts.limit > 0) params.set("limit", String(opts.limit)); if (opts?.direction) params.set("direction", opts.direction); const res = await this.fetchWithTimeout(`${this.baseUrl}/api/datasources/proxy/uid/${dsUid}/loki/api/v1/query_range?${params}`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError("query loki range", res.status, body); } return (await res.json()); } // ── Tempo query methods ────────────────────────────────────────────── /** Search traces via TraceQL or basic query parameters. */ async searchTraces(dsUid, query, opts) { const params = new URLSearchParams({ q: query }); if (opts?.start) params.set("start", parseDateMathToSeconds(opts.start)); if (opts?.end) params.set("end", parseDateMathToSeconds(opts.end)); if (opts?.limit) params.set("limit", String(opts.limit)); if (opts?.minDuration) params.set("minDuration", opts.minDuration); if (opts?.maxDuration) params.set("maxDuration", opts.maxDuration); if (opts?.spss) params.set("spss", String(opts.spss)); const res = await this.fetchWithTimeout(`${this.baseUrl}/api/datasources/proxy/uid/${dsUid}/api/search?${params}`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError("search traces", res.status, body); } return (await res.json()); } /** Get a full trace by trace ID. */ async getTrace(dsUid, traceId) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/datasources/proxy/uid/${dsUid}/api/traces/${traceId}`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError(`get trace ${traceId}`, res.status, body); } return (await res.json()); } // ── Annotation methods ────────────────────────────────────────────── /** Create an annotation on a dashboard or globally. */ async createAnnotation(req) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/annotations`, { method: "POST", headers: this.headers, body: JSON.stringify(req), }); if (!res.ok) { const body = await res.text(); throw this.classifyError("create annotation", res.status, body); } return (await res.json()); } /** Query annotations with optional filters. */ async getAnnotations(params) { const qs = new URLSearchParams(); if (params.from) qs.set("from", String(params.from)); if (params.to) qs.set("to", String(params.to)); if (params.dashboardUID) qs.set("dashboardUID", params.dashboardUID); if (params.panelId) qs.set("panelId", String(params.panelId)); if (params.tags) { for (const tag of params.tags) qs.append("tags", tag); } if (params.limit) qs.set("limit", String(params.limit)); const res = await this.fetchWithTimeout(`${this.baseUrl}/api/annotations?${qs}`, { headers: this.headers, }); if (!res.ok) { const body = await res.text(); throw this.classifyError("get annotations", res.status, body); } return (await res.json()); } // ── Alert rule methods ────────────────────────────────────────────── /** * Create an alert rule via Grafana's Unified Alerting provisioning API. * Sends X-Disable-Provenance so agent-created rules remain editable in UI. */ async createAlertRule(req) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/v1/provisioning/alert-rules`, { method: "POST", headers: { ...this.headers, "X-Disable-Provenance": "true", }, body: JSON.stringify(req), }); if (!res.ok) { const body = await res.text(); throw this.classifyError("create alert rule", res.status, body); } return (await res.json()); } /** List all alert rules. */ async listAlertRules() { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/v1/provisioning/alert-rules`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError("list alert rules", res.status, body); } return (await res.json()); } /** Delete an alert rule by UID. */ async deleteAlertRule(uid) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/v1/provisioning/alert-rules/${uid}`, { method: "DELETE", headers: this.headers, }); if (!res.ok) { const body = await res.text(); throw this.classifyError(`delete alert rule ${uid}`, res.status, body); } } /** * Fetch evaluation state for all alert rules via Grafana's Prometheus-compatible endpoint. * Returns a map of rule UID → state info for efficient lookup. */ async getAlertRuleStates() { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/prometheus/grafana/api/v1/rules`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError("get alert rule states", res.status, body); } const json = (await res.json()); const stateMap = new Map(); for (const group of json.data?.groups ?? []) { for (const rule of group.rules ?? []) { if (rule.uid) { stateMap.set(rule.uid, { uid: rule.uid, state: (rule.state ?? "unknown"), health: (rule.health ?? "unknown"), lastEvaluation: rule.lastEvaluation ?? "", evaluationTime: rule.evaluationTime ?? 0, isPaused: rule.isPaused ?? false, }); } } } return stateMap; } // ── Folder methods ────────────────────────────────────────────────── /** Create a folder for organizing dashboards and alert rules. */ async createFolder(req) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/folders`, { method: "POST", headers: this.headers, body: JSON.stringify(req), }); if (!res.ok) { const body = await res.text(); throw this.classifyError("create folder", res.status, body); } return (await res.json()); } /** List folders. */ async listFolders() { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/folders?limit=100`, { headers: this.headers, }); if (!res.ok) { const body = await res.text(); throw this.classifyError("list folders", res.status, body); } return (await res.json()); } // ── Contact point methods ─────────────────────────────────────────── /** List configured alert contact points. */ async listContactPoints() { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/v1/provisioning/contact-points`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError("list contact points", res.status, body); } return (await res.json()); } /** Create a contact point (webhook, email, etc.). */ async createContactPoint(req) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/v1/provisioning/contact-points`, { method: "POST", headers: { ...this.headers, "X-Disable-Provenance": "true", }, body: JSON.stringify(req), }); if (!res.ok) { const body = await res.text(); throw this.classifyError("create contact point", res.status, body); } return (await res.json()); } /** Update an existing contact point by UID. */ async updateContactPoint(uid, req) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/v1/provisioning/contact-points/${uid}`, { method: "PUT", headers: { ...this.headers, "X-Disable-Provenance": "true", }, body: JSON.stringify(req), }); if (!res.ok) { const body = await res.text(); throw this.classifyError(`update contact point ${uid}`, res.status, body); } } /** Delete a contact point by UID. */ async deleteContactPoint(uid) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/v1/provisioning/contact-points/${uid}`, { method: "DELETE", headers: { ...this.headers, "X-Disable-Provenance": "true", }, }); if (!res.ok) { const body = await res.text(); throw this.classifyError(`delete contact point ${uid}`, res.status, body); } } /** Get the notification policy tree. */ async getNotificationPolicies() { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/v1/provisioning/policies`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError("get notification policies", res.status, body); } return (await res.json()); } /** Update the full notification policy tree. */ async updateNotificationPolicies(tree) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/v1/provisioning/policies`, { method: "PUT", headers: { ...this.headers, "X-Disable-Provenance": "true", }, body: JSON.stringify(tree), }); if (!res.ok) { const body = await res.text(); throw this.classifyError("update notification policies", res.status, body); } } // ── Dashboard delete ──────────────────────────────────────────────── /** Delete a dashboard by UID. */ async deleteDashboard(uid) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/dashboards/uid/${uid}`, { method: "DELETE", headers: this.headers, }); if (!res.ok) { const body = await res.text(); throw this.classifyError(`delete dashboard ${uid}`, res.status, body); } return (await res.json()); } // ── Alertmanager silence methods ─────────────────────────────────── /** Create a silence for matching alerts. */ async createSilence(matchers, duration, comment, createdBy) { const now = new Date(); const durationMs = parseDuration(duration); const endsAt = new Date(now.getTime() + durationMs); const res = await this.fetchWithTimeout(`${this.baseUrl}/api/alertmanager/grafana/api/v2/silences`, { method: "POST", headers: this.headers, body: JSON.stringify({ matchers, startsAt: now.toISOString(), endsAt: endsAt.toISOString(), comment, createdBy: createdBy ?? "grafana-lens", }), }); if (!res.ok) { const body = await res.text(); throw this.classifyError("create silence", res.status, body); } return (await res.json()); } /** List all silences. */ async listSilences() { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/alertmanager/grafana/api/v2/silences`, { headers: this.headers }); if (!res.ok) { const body = await res.text(); throw this.classifyError("list silences", res.status, body); } return (await res.json()); } /** Delete (expire) a silence by ID. */ async deleteSilence(silenceId) { const res = await this.fetchWithTimeout(`${this.baseUrl}/api/alertmanager/grafana/api/v2/silence/${silenceId}`, { method: "DELETE", headers: this.headers, }); if (!res.ok) { const body = await res.text(); throw this.classifyError(`delete silence ${silenceId}`, res.status, body); } } /** Classify render-specific errors with actionable messages. */ classifyRenderError(uid, panelId, status, body) { switch (status) { case 404: return new Error(`Panel or dashboard not found (uid: ${uid}, panel: ${panelId})`); case 401: case 403: return new Error("Grafana authentication failed — check your service account token"); case 502: return new Error("Grafana Image Renderer not available — ensure the Image Renderer plugin is installed. See https://grafana.com/docs/grafana/latest/setup-grafana/image-rendering/"); default: return new Error(`Grafana render error ${status}: ${body}`); } } /** Classify general API errors with actionable messages. */ classifyError(operation, status, body) { switch (status) { case 401: case 403: return new Error(`Grafana authentication failed — check your service account token (${operation})`); case 404: return new Error(`Not found: ${operation}`); case 409: return new Error(`Resource already exists (${operation}) — use a different name or update the existing one`); case 422: return new Error(`Validation error (${operation}): ${body} — check parameter formats`); case 429: return new Error(`Rate limited (${operation}) — wait a moment and retry`); default: return new Error(`Grafana API error ${status} (${operation}): ${body}`); } } } // ── Helpers ──────────────────────────────────────────────────────────── /** Parse a duration string like "2h", "30m", "1d" into milliseconds. */ function parseDuration(duration) { const match = duration.match(/^(\d+)\s*(s|m|h|d)$/); if (!match) { // Default: treat as hours const n = parseInt(duration, 10); return isNaN(n) ? 2 * 60 * 60 * 1000 : n * 60 * 60 * 1000; } const val = parseInt(match[1], 10); switch (match[2]) { case "s": return val * 1000; case "m": return val * 60 * 1000; case "h": return val * 60 * 60 * 1000; case "d": return val * 24 * 60 * 60 * 1000; default: return val * 60 * 60 * 1000; } } /** * Convert Grafana date math to Unix nanoseconds (for Loki). * Accepted: "now", "now-1h", "now-30m", "now-7d", "now-2w", * RFC3339 ("2026-01-15T00:00:00Z"), Unix seconds, Unix nanoseconds. * Throws on unrecognized formats with recovery guidance. */ export function parseDateMathToNs(time) { // Already nanosecond timestamp (16+ digits) if (/^\d{16,}$/.test(time)) return time; // Unix seconds/ms → convert to ns if (/^\d{1,15}(\.\d+)?$/.test(time)) { const n = Number(time); if (n < 1e12) return String(Math.floor(n * 1e9)); // seconds → ns if (n < 1e15) return String(Math.floor(n * 1e6)); // milliseconds → ns return String(Math.floor(n)); // already ns-scale } // Date math: now, now-Xh, now-Xm, now-Xs, now-Xd, now-Xw const match = time.match(/^now(?:-([\d.]+)([smhdw]))?$/); if (match) { const [, amount, unit] = match; let offsetMs = 0; if (amount && unit) { const n = parseFloat(amount); const mult = { s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 }; offsetMs = n * (mult[unit] ?? 0); } return String(Math.floor((Date.now() - offsetMs) * 1_000_000)); } // RFC3339 / ISO8601 const parsed = Date.parse(time); if (!isNaN(parsed)) return String(parsed * 1_000_000); // No silent fallback — throw with actionable guidance throw new Error(`Invalid time format '${time}'. Accepted: 'now', 'now-1h', 'now-30m', 'now-7d', Unix seconds (e.g., '1700000000'), or RFC3339 (e.g., '2026-01-15T00:00:00Z').`); } /** * Convert Grafana date math to Unix seconds (for Prometheus). * Same accepted formats as parseDateMathToNs(). */ export function parseDateMathToSeconds(time) { const ns = parseDateMathToNs(time); return String(Math.floor(Number(ns) / 1e9)); } /** * Convert Grafana date math to epoch milliseconds (for Annotations API). * Same accepted formats as parseDateMathToNs(). */ export function parseDateMathToMs(time) { const ns = parseDateMathToNs(time); return Math.floor(Number(ns) / 1_000_000); } /** Escape special regex characters in user input for safe use in Prometheus match[] selectors. */ export function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }