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

250 lines (249 loc) 12.1 kB
/** * 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}` }); } }, }); }