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
250 lines (249 loc) • 12.1 kB
JavaScript
/**
* grafana_get_dashboard tool
*
* Returns a compact summary of a dashboard — panels, their types, and
* the queries they run. Keeps agent context small (full dashboard JSON
* can be huge) while giving enough info to compose workflows.
*
* The `audit` mode dry-runs each panel's queries against Grafana to
* check which panels return data and which are broken — one tool call
* replaces N separate grafana_query calls.
*/
import { jsonResult, readStringParam } from "../sdk-compat.js";
import { instanceProperties } from "./instance-param.js";
import { getQueryCapability } from "./explore-datasources.js";
// ── Helpers ────────────────────────────────────────────────────────────
/**
* Resolve a panel's effective datasource UID.
*
* Dashboard panels reference datasources via `{ type, uid }` where uid
* can be a template variable like `$prometheus`. We resolve these to
* concrete UIDs by matching the variable's type against available
* datasources.
*/
function resolveDatasourceUid(panelDs, datasources) {
// No datasource specified — Grafana uses the default datasource
if (!panelDs || typeof panelDs !== "object") {
const def = datasources.find((d) => d.isDefault);
return def ? { uid: def.uid, type: def.type } : null;
}
const ds = panelDs;
const uid = ds.uid;
const dsType = ds.type;
if (!uid)
return null;
// Concrete UID — look up its type
if (!uid.startsWith("$")) {
const found = datasources.find((d) => d.uid === uid);
return found ? { uid: found.uid, type: found.type } : (dsType ? { uid, type: dsType } : null);
}
// Template variable (e.g. $prometheus, $loki) — resolve by type
if (dsType) {
const found = datasources.find((d) => d.type === dsType);
if (found)
return { uid: found.uid, type: found.type };
}
return null;
}
/**
* Replace Grafana template variables in PromQL/LogQL expressions with
* wildcards so dry-run queries can match any label value.
*
* `$variable` and `${variable}` → `.*`
* `$__range`, `$__rate_interval`, `$__interval` → `5m` (safe default)
*/
function replaceTemplateVars(expr) {
return expr
.replace(/\$__(?:range|rate_interval|interval)/g, "5m")
.replace(/\$\{[a-zA-Z_]\w*\}/g, ".*")
.replace(/\$[a-zA-Z_]\w*/g, ".*");
}
/**
* Check if a sanitized expression is unauditable — e.g. when a template
* variable represented a metric name and was replaced with `.*`, which
* is not valid PromQL/LogQL by itself.
*/
function isUnauditableExpr(sanitized) {
const trimmed = sanitized.trim();
// Bare `.*` or wrapped in simple function like `rate(.*[5m])`
if (trimmed === ".*")
return true;
if (/^\w+\(\.\*\[/.test(trimmed))
return true;
return false;
}
/**
* Dry-run a single panel's first query and classify the result.
*
* Bug fixes applied:
* - LogQL: uses queryLokiRange (not instant) because Loki rejects pure
* log queries on the instant endpoint.
* - Metric-name variables: detects when replaceTemplateVars produces an
* unauditable expression (e.g. bare `.*`) and skips instead of erroring.
*/
async function auditPanel(client, dsUid, dsType, expr) {
const cap = getQueryCapability(dsType);
if (!cap.supported) {
return { status: "skipped", error: `Unsupported datasource type: ${dsType}` };
}
const sanitized = replaceTemplateVars(expr);
// Skip when variable replacement produces an unauditable expression
if (isUnauditableExpr(sanitized)) {
return { status: "skipped", error: "Expression depends on metric-name variable" };
}
try {
if (cap.queryTool === "grafana_query") {
const result = await client.queryPrometheus(dsUid, sanitized);
const results = result.data?.result ?? [];
if (results.length === 0)
return { status: "nodata" };
const firstVal = parseFloat(results[0].value[1]);
return { status: "ok", sampleValue: isNaN(firstVal) ? undefined : firstVal };
}
// LogQL — use range query; Loki rejects log queries on instant endpoint
const result = await client.queryLokiRange(dsUid, sanitized, "now-1h", "now", { limit: 1 });
const entries = result.data?.result ?? [];
if (entries.length === 0)
return { status: "nodata" };
return { status: "ok" };
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { status: "error", error: msg };
}
}
// ── Tool factory ───────────────────────────────────────────────────────
export function createGetDashboardToolFactory(registry) {
return (_ctx) => ({
name: "grafana_get_dashboard",
label: "Get Dashboard",
description: [
"Get a summary of a Grafana dashboard.",
"WORKFLOW: Use to inspect dashboard structure — find panel IDs for grafana_share_dashboard,",
"understand what queries a dashboard uses, or verify a dashboard was created correctly.",
"Set audit=true to dry-run each panel's queries and check which return data vs broken (replaces N separate grafana_query calls).",
"Set compact=true when scanning multiple dashboards for overview (returns panel titles/types only, ~70% smaller).",
"Omit both when you need full query details (before update or share).",
].join(" "),
parameters: {
type: "object",
properties: {
...instanceProperties(registry),
uid: {
type: "string",
description: "Dashboard UID (from grafana_create_dashboard or grafana_search result)",
},
compact: {
type: "boolean",
description: "Return minimal response — panel titles and types only, no queries or metadata. Use when reviewing multiple dashboards for an overview. Default: false",
},
audit: {
type: "boolean",
description: "Dry-run each panel's PromQL/LogQL query and report health: ok (has data), nodata (empty), error (query failed), skipped (no query or unsupported datasource). Replaces calling grafana_query per panel. Default: false",
},
},
required: ["uid"],
},
async execute(_toolCallId, params) {
const client = registry.get(readStringParam(params, "instance"));
const uid = readStringParam(params, "uid", { required: true, label: "Dashboard UID" });
const compact = typeof params.compact === "boolean" ? params.compact : false;
const audit = typeof params.audit === "boolean" ? params.audit : false;
try {
const data = await client.getDashboard(uid);
const dashboard = data.dashboard;
const meta = data.meta;
const panels = dashboard.panels ?? [];
const base = {
status: "success",
uid: dashboard.uid ?? uid,
title: dashboard.title,
url: client.dashboardUrl(uid),
tags: dashboard.tags ?? [],
panelCount: panels.length,
};
// ── Compact mode ──────────────────────────────────────────────
if (compact) {
return jsonResult({
...base,
panels: panels.map((p) => ({
id: p.id,
title: p.title,
type: p.type,
})),
});
}
// ── Resolve datasources for audit ─────────────────────────────
let datasources = [];
if (audit) {
datasources = await client.listDatasources();
}
// ── Build panel summaries ─────────────────────────────────────
const panelSummaries = panels.map((p) => {
const targets = p.targets ?? [];
return {
id: p.id,
title: p.title,
type: p.type,
queries: targets.map((t) => ({
refId: t.refId,
expr: t.expr ?? undefined,
})).filter((q) => q.expr),
};
});
// ── Audit mode: dry-run queries ───────────────────────────────
let auditResults;
if (audit) {
auditResults = new Map();
const auditPromises = panelSummaries.map(async (panel, idx) => {
// Skip panels with no queries (rows, text, etc.)
if (panel.queries.length === 0) {
auditResults.set(panel.id, { status: "skipped" });
return;
}
const resolved = resolveDatasourceUid(panels[idx].datasource, datasources);
if (!resolved) {
auditResults.set(panel.id, {
status: "skipped",
error: "Could not resolve datasource",
});
return;
}
// Audit the first query (representative of panel health)
const firstExpr = panel.queries[0].expr;
const health = await auditPanel(client, resolved.uid, resolved.type, firstExpr);
auditResults.set(panel.id, health);
});
await Promise.allSettled(auditPromises);
}
// ── Build response ────────────────────────────────────────────
const responsePanels = panelSummaries.map((panel) => ({
...panel,
...(auditResults ? { health: auditResults.get(panel.id) ?? { status: "skipped" } } : {}),
}));
const response = {
...base,
description: dashboard.description ?? undefined,
time: dashboard.time ?? undefined,
refresh: dashboard.refresh ?? undefined,
panels: responsePanels,
folderUid: meta?.folderUid ?? undefined,
created: meta?.created ?? undefined,
updated: meta?.updated ?? undefined,
};
// Add audit summary when auditing
if (auditResults) {
const counts = { ok: 0, nodata: 0, error: 0, skipped: 0 };
for (const h of auditResults.values())
counts[h.status]++;
response.auditSummary = counts;
}
return jsonResult(response);
}
catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return jsonResult({ error: `Failed to get dashboard: ${reason}` });
}
},
});
}