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
JavaScript
/**
* 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, "\\$&");
}