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

227 lines (226 loc) 11.1 kB
/** * grafana_share_dashboard tool * * Renders dashboard panels as PNG images and delivers them via the MEDIA: * pattern so they appear inline in the user's messaging channel. * * Three-tier fallback: render PNG → snapshot URL → deep link. * This ensures the user always gets *something*, even if the Grafana * Image Renderer plugin isn't installed. * * imageResult() is NOT exported from plugin-sdk — we construct the * AgentToolResult manually with both text (MEDIA: prefix) and image content. */ import { jsonResult, readStringParam, readNumberParam } from "../sdk-compat.js"; import { unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { instanceProperties } from "./instance-param.js"; // ── PNG dimension validation ───────────────────────────────────────── /** * Extract width/height from a PNG IHDR chunk (bytes 16-23, big-endian). * Returns null if the buffer is too small or not a valid PNG. */ export function getPNGDimensions(buffer) { if (buffer.byteLength < 24) return null; const sig = new Uint8Array(buffer, 0, 4); // PNG magic: 0x89 0x50 0x4E 0x47 if (sig[0] !== 0x89 || sig[1] !== 0x50 || sig[2] !== 0x4E || sig[3] !== 0x47) return null; const view = new DataView(buffer); return { width: view.getUint32(16, false), height: view.getUint32(20, false) }; } /** * Classify a render error into actionable information for the agent. * Infers renderer availability from the error type — 502 means the * Image Renderer plugin is missing; other errors mean the renderer * exists but something else went wrong. */ export function classifyRenderFailure(err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("Image Renderer not available")) { return { rendererAvailable: false, renderFailureReason: "Image Renderer plugin not installed", remediation: "Install grafana-image-renderer plugin for PNG export: https://grafana.com/docs/grafana/latest/setup-grafana/image-rendering/", }; } if (message.includes("authentication failed")) { return { rendererAvailable: true, renderFailureReason: message, remediation: "Check that the service account token has viewer permissions on this dashboard", }; } // For 404 (panel/dashboard not found) and other errors, // the renderer is available — the request itself was the problem return { rendererAvailable: true, renderFailureReason: message, }; } export function createShareDashboardToolFactory(registry) { return (_ctx) => ({ name: "grafana_share_dashboard", label: "Share Dashboard", description: [ "Render a Grafana dashboard panel as an image and deliver it inline in messaging.", "WORKFLOW: Use after creating a dashboard or when user asks to 'show me' a chart.", "Requires dashboardUid and panelId. Three-tier fallback: PNG image → snapshot URL → deep link.", "Response includes rendererAvailable flag and remediation guidance when image rendering fails.", "Use grafana_search to find the dashboard UID, then grafana_get_dashboard to find panel IDs.", ].join(" "), parameters: { type: "object", properties: { ...instanceProperties(registry), dashboardUid: { type: "string", description: "Dashboard UID (from grafana_create_dashboard or grafana_search result)", }, panelId: { type: "number", description: "Panel ID to render (use grafana_get_dashboard to find panel IDs)", }, from: { type: "string", description: "Time range start (e.g., 'now-6h', 'now-1d'). Default: 'now-6h'", }, to: { type: "string", description: "Time range end (e.g., 'now'). Default: 'now'", }, width: { type: "number", description: "Image width in pixels. Default: 1000", }, height: { type: "number", description: "Image height in pixels. Default: 500", }, theme: { type: "string", enum: ["light", "dark"], description: "Dashboard theme. Default: 'dark'", }, }, required: ["dashboardUid", "panelId"], }, async execute(_toolCallId, params) { const client = registry.get(readStringParam(params, "instance")); const dashboardUid = readStringParam(params, "dashboardUid", { required: true, label: "Dashboard UID" }); const panelId = readNumberParam(params, "panelId", { required: true, label: "Panel ID" }); const from = readStringParam(params, "from") ?? "now-6h"; const to = readStringParam(params, "to") ?? "now"; const width = readNumberParam(params, "width") ?? 1000; const height = readNumberParam(params, "height") ?? 500; const theme = (readStringParam(params, "theme") ?? "dark"); const dashboardUrl = client.dashboardUrl(dashboardUid); // Pre-validate: fetch dashboard and check that panelId exists let dashData = null; try { dashData = await client.getDashboard(dashboardUid); const panels = (dashData.dashboard.panels ?? []); // Flatten row panels — rows contain nested panels const allPanels = panels.flatMap((p) => p.type === "row" && Array.isArray(p.panels) ? [p, ...p.panels] : [p]); const panelExists = allPanels.some((p) => p.id === panelId); if (!panelExists) { const validIds = allPanels.filter((p) => p.type !== "row").map((p) => `${p.id} (${p.title})`); return jsonResult({ error: `Panel ${panelId} not found in dashboard ${dashboardUid}`, availablePanels: validIds, dashboardUrl, }); } } catch { // Dashboard fetch failed — continue to render attempt (render will fail with its own error) } // Track render failure for Tier 2/3 responses let renderFailure = null; // Tier 1: Try rendering panel as PNG try { const imageBuffer = await client.renderPanel(dashboardUid, panelId, { width, height, from, to, theme, }); // Detect placeholder PNG: Grafana returns HTTP 200 with a static // 478×208 warning image when Image Renderer is not installed. // A real render matches the requested dimensions. const dims = getPNGDimensions(imageBuffer); if (dims && (dims.width !== width || dims.height !== height)) { throw new Error("Image Renderer not available — Grafana returned a placeholder image instead of the rendered panel"); } const base64 = Buffer.from(imageBuffer).toString("base64"); const tmpPath = join(tmpdir(), `grafana-lens-${Date.now()}-panel-${panelId}.png`); await writeFile(tmpPath, Buffer.from(imageBuffer)); // Best-effort cleanup after 30s (gives media parser time to read the file) setTimeout(() => { unlink(tmpPath).catch(() => { }); }, 30_000); // Construct AgentToolResult manually (imageResult not exported from SDK) return { content: [ { type: "text", text: `MEDIA:${tmpPath}\nPanel ${panelId} from dashboard ${dashboardUid} (${from} to ${to}).\nDashboard: ${dashboardUrl}`, }, { type: "image", data: base64, mimeType: "image/png", }, ], details: { dashboardUid, panelId, path: tmpPath, deliveryTier: "image", rendererAvailable: true, }, }; } catch (err) { renderFailure = classifyRenderFailure(err); } // Tier 2: Try creating a snapshot (reuse dashData if already fetched) try { if (!dashData) { dashData = await client.getDashboard(dashboardUid); } const dashboard = dashData.dashboard; const snapshot = await client.createSnapshot(dashboard, { name: `Panel ${panelId} snapshot`, expires: 86400, // 24h }); return jsonResult({ status: "snapshot", deliveryTier: "snapshot", snapshotUrl: snapshot.url, dashboardUrl, rendererAvailable: renderFailure?.rendererAvailable ?? false, renderFailureReason: renderFailure?.renderFailureReason, ...(renderFailure?.remediation ? { remediation: renderFailure.remediation } : {}), message: `Image rendering unavailable — created a snapshot instead. View: ${snapshot.url}`, }); } catch (snapshotErr) { // Tier 2 failed — fall back to deep link with both failure reasons const snapshotReason = snapshotErr instanceof Error ? snapshotErr.message : String(snapshotErr); return jsonResult({ status: "link", deliveryTier: "link", dashboardUrl, rendererAvailable: renderFailure?.rendererAvailable ?? false, renderFailureReason: renderFailure?.renderFailureReason, snapshotFailureReason: snapshotReason, remediation: renderFailure?.remediation ?? "Install grafana-image-renderer plugin for PNG export or check Grafana snapshot API permissions", message: `Image rendering and snapshots unavailable. View the dashboard directly: ${dashboardUrl}`, }); } }, }); }