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

358 lines (357 loc) 16.2 kB
/** * grafana_update_dashboard tool * * Five operations in one tool: * - add_panel: Add a new panel with auto-layout + query validation * - remove_panel: Remove a panel by ID or title * - update_panel: Merge updates into an existing panel + query validation * - update_metadata: Change title, description, tags, time range, refresh * - delete: Permanently remove a dashboard * * Uses the same POST /api/dashboards/db endpoint as create-dashboard, * but preserves dashboard.id and version for update semantics. * * Query validation: When adding or updating panel targets, PromQL expressions * are dry-run against Grafana. The panel is always saved regardless — validation * is informational, included as a queryValidation object in the response. */ import { jsonResult, readStringParam, readNumberParam } from "../sdk-compat.js"; import { instanceProperties } from "./instance-param.js"; export function createUpdateDashboardToolFactory(registry) { return (_ctx) => ({ name: "grafana_update_dashboard", label: "Update Dashboard", description: [ "Modify or delete an existing Grafana dashboard — add panels, remove panels, update panel queries, change metadata, or delete the dashboard entirely.", "WORKFLOW: Always call grafana_get_dashboard first to get panel IDs and current structure.", "Use operation 'add_panel' to append a panel (auto-layouts below existing panels).", "Use 'remove_panel' to delete a panel by ID or title. Use 'update_panel' to merge changes into a panel.", "Use 'update_metadata' to change title, description, tags, time range, or auto-refresh.", "Use 'delete' to permanently remove a dashboard (cannot be undone — confirm with user first).", "Returns the updated dashboard URL and a summary of changes.", "For add_panel and update_panel (when targets change), PromQL queries are dry-run and a queryValidation object is included: {valid, error?, sampleValue?} per target. The panel is always saved — validation is informational.", ].join(" "), parameters: { type: "object", properties: { ...instanceProperties(registry), uid: { type: "string", description: "Dashboard UID (from grafana_get_dashboard or grafana_search)", }, operation: { type: "string", enum: ["add_panel", "remove_panel", "update_panel", "update_metadata", "delete"], description: "Operation to perform on the dashboard", }, panel: { type: "object", description: "Panel definition for add_panel. Must include title, type, and targets. Example: { title: 'Error Rate', type: 'timeseries', targets: [{ refId: 'A', expr: 'rate(errors[5m])' }] }", }, panelId: { type: "number", description: "Panel ID for remove_panel or update_panel (from grafana_get_dashboard)", }, panelTitle: { type: "string", description: "Panel title fallback for remove/update — case-insensitive substring match. Use panelId when possible.", }, updates: { type: "object", description: "Fields to merge into the panel for update_panel. Example: { title: 'New Title', targets: [...] }. targets replaces entirely if provided.", }, title: { type: "string", description: "New dashboard title (update_metadata)" }, description: { type: "string", description: "New dashboard description (update_metadata)" }, tags: { type: "array", items: { type: "string" }, description: "New dashboard tags (update_metadata)", }, time: { type: "object", description: 'Dashboard time range (update_metadata). Example: { "from": "now-7d", "to": "now" }', }, refresh: { type: "string", description: 'Auto-refresh interval (update_metadata). Example: "1m", "5m", "30s"', }, }, required: ["uid", "operation"], }, async execute(_toolCallId, params) { const client = registry.get(readStringParam(params, "instance")); const uid = readStringParam(params, "uid", { required: true, label: "Dashboard UID" }); const operation = readStringParam(params, "operation", { required: true, label: "Operation" }); // Fetch existing dashboard — preserves id and version for update let data; try { data = await client.getDashboard(uid); } catch (err) { const reason = err instanceof Error ? err.message : String(err); return jsonResult({ error: `Failed to get dashboard: ${reason}` }); } const dashboard = data.dashboard; const meta = data.meta; // Safety: provisioned dashboards reject API updates if (meta?.provisioned === true && operation !== "delete") { return jsonResult({ error: "Dashboard is provisioned and cannot be modified via API. Create a new dashboard with grafana_create_dashboard instead, or ask the user to modify the provisioning source file.", }); } const panels = (dashboard.panels ?? []); switch (operation) { case "add_panel": return handleAddPanel(client, dashboard, meta, panels, params); case "remove_panel": return handleRemovePanel(client, dashboard, meta, panels, params); case "update_panel": return handleUpdatePanel(client, dashboard, meta, panels, params); case "update_metadata": return handleUpdateMetadata(client, dashboard, meta, params); case "delete": return handleDelete(client, uid, dashboard); default: return jsonResult({ error: `Unknown operation '${operation}'. Use: add_panel, remove_panel, update_panel, update_metadata, delete`, }); } }, }); } // ── Query validation ───────────────────────────────────────────────── /** Grafana template variables (e.g., `${DS_PROMETHEUS}`) can't be used for API queries. */ function isTemplateVariable(uid) { return uid.startsWith("${") && uid.endsWith("}"); } /** Extract the first concrete datasource UID from a panel's targets or panel-level datasource. */ function firstUidFromPanel(panel) { const targets = panel.targets; if (targets) { for (const t of targets) { if (t.datasource?.uid && !isTemplateVariable(t.datasource.uid)) return t.datasource.uid; } } const ds = panel.datasource; if (ds?.uid && !isTemplateVariable(ds.uid)) return ds.uid; return undefined; } /** * Resolve a Prometheus datasource UID for query validation. * Checks: panel targets → panel datasource → existing panels → undefined. */ function resolveDatasourceUid(panel, existingPanels) { return firstUidFromPanel(panel) ?? existingPanels.reduce((found, p) => found ?? firstUidFromPanel(p), undefined); } const VALIDATION_SKIP_REASON = "No datasource UID found on panel or existing panels — set datasource.uid on targets to enable validation"; /** * Dry-run PromQL expressions from panel targets to validate them. * Returns a QueryValidation object — never throws. */ export async function validateTargetQueries(client, targets, datasourceUid) { const exprs = targets.filter((t) => t.expr); if (exprs.length === 0) { return { validated: false, results: [], skippedReason: "No PromQL expressions in targets" }; } const results = await Promise.all(exprs.map(async (t) => { const refId = t.refId ?? "?"; const expr = t.expr; try { const result = await client.queryPrometheus(datasourceUid, expr); const first = result.data.result[0]; return { refId, expr, valid: true, ...(first ? { sampleValue: Number(first.value[1]) } : {}), }; } catch (err) { const reason = err instanceof Error ? err.message : String(err); return { refId, expr, valid: false, error: reason }; } })); return { validated: true, results, datasourceUid }; } /** * Resolve datasource + validate targets in one call. Returns undefined when * there are no targets to validate (e.g., text panels, title-only updates). */ async function maybeValidateQueries(client, targets, panel, allPanels) { if (!targets || targets.length === 0) return undefined; const dsUid = resolveDatasourceUid(panel, allPanels); if (!dsUid) { return { validated: false, results: [], skippedReason: VALIDATION_SKIP_REASON }; } return validateTargetQueries(client, targets, dsUid); } // ── Operation handlers ────────────────────────────────────────────── async function handleAddPanel(client, dashboard, meta, panels, params) { const panel = params.panel; if (!panel || !panel.title || !panel.type) { return jsonResult({ error: "add_panel requires a 'panel' object with at least 'title' and 'type'. Example: { panel: { title: 'Error Rate', type: 'timeseries', targets: [...] } }", }); } // Auto-assign panel ID const maxId = panels.reduce((max, p) => Math.max(max, p.id ?? 0), 0); panel.id = maxId + 1; // Auto-layout: place below existing panels unless explicit gridPos if (!panel.gridPos) { panel.gridPos = computeNextGridPos(panels); } panels.push(panel); dashboard.panels = panels; const queryValidation = await maybeValidateQueries(client, panel.targets, panel, panels); return saveDashboard(client, dashboard, meta, "add_panel", { id: panel.id, title: panel.title, }, undefined, queryValidation); } function handleRemovePanel(client, dashboard, meta, panels, params) { const panelId = readNumberParam(params, "panelId"); const panelTitle = readStringParam(params, "panelTitle"); const target = findPanel(panels, panelId, panelTitle); if (!target) { const available = panels.map((p) => `id=${p.id} "${p.title}"`).join(", "); return jsonResult({ error: `Panel not found. Available panels: ${available || "(none)"}`, }); } const removed = { id: target.id, title: target.title }; dashboard.panels = panels.filter((p) => p !== target); return saveDashboard(client, dashboard, meta, "remove_panel", removed); } async function handleUpdatePanel(client, dashboard, meta, panels, params) { const panelId = readNumberParam(params, "panelId"); const panelTitle = readStringParam(params, "panelTitle"); const updates = params.updates; const target = findPanel(panels, panelId, panelTitle); if (!target) { const available = panels.map((p) => `id=${p.id} "${p.title}"`).join(", "); return jsonResult({ error: `Panel not found. Available panels: ${available || "(none)"}`, }); } if (!updates || Object.keys(updates).length === 0) { return jsonResult({ error: "update_panel requires a non-empty 'updates' object" }); } Object.assign(target, updates); const queryValidation = await maybeValidateQueries(client, updates.targets, target, panels); return saveDashboard(client, dashboard, meta, "update_panel", { id: target.id, title: target.title, }, undefined, queryValidation); } function handleUpdateMetadata(client, dashboard, meta, params) { const title = readStringParam(params, "title"); const description = readStringParam(params, "description"); const tags = params.tags; const time = params.time; const refresh = readStringParam(params, "refresh"); const changed = []; if (title !== undefined) { dashboard.title = title; changed.push("title"); } if (description !== undefined) { dashboard.description = description; changed.push("description"); } if (tags !== undefined) { dashboard.tags = tags; changed.push("tags"); } if (time !== undefined) { dashboard.time = time; changed.push("time"); } if (refresh !== undefined) { dashboard.refresh = refresh; changed.push("refresh"); } if (changed.length === 0) { return jsonResult({ error: "update_metadata requires at least one field: title, description, tags, time, or refresh", }); } return saveDashboard(client, dashboard, meta, "update_metadata", undefined, changed); } async function handleDelete(client, uid, dashboard) { try { const result = await client.deleteDashboard(uid); return jsonResult({ status: "deleted", uid, title: result.title ?? dashboard.title, message: `Dashboard "${result.title ?? dashboard.title}" has been permanently deleted.`, }); } catch (err) { const reason = err instanceof Error ? err.message : String(err); return jsonResult({ error: `Failed to delete dashboard: ${reason}` }); } } // ── Helpers ────────────────────────────────────────────────────────── function findPanel(panels, panelId, panelTitle) { if (panelId !== undefined) { return panels.find((p) => p.id === panelId); } if (panelTitle !== undefined) { const lower = panelTitle.toLowerCase(); return panels.find((p) => (p.title ?? "").toLowerCase().includes(lower)); } return undefined; } function computeNextGridPos(panels) { if (panels.length === 0) { return { x: 0, y: 0, w: 12, h: 8 }; } let maxBottom = 0; for (const p of panels) { const gp = p.gridPos; if (gp) { const bottom = gp.y + gp.h; if (bottom > maxBottom) maxBottom = bottom; } } return { x: 0, y: maxBottom, w: 12, h: 8 }; } async function saveDashboard(client, dashboard, meta, operation, affectedPanel, changedFields, queryValidation) { try { const result = await client.createDashboard({ dashboard, folderUid: meta?.folderUid ?? undefined, message: `Updated by Grafana Lens agent (${operation})`, overwrite: true, }); const response = { status: "updated", uid: result.uid, url: client.dashboardUrl(result.uid), version: result.version, operation, panelCount: (dashboard.panels ?? []).length, message: `Dashboard updated successfully (${operation}).`, }; if (affectedPanel) { response.affectedPanel = affectedPanel; } if (changedFields) { response.changedFields = changedFields; } if (queryValidation) { response.queryValidation = queryValidation; } return jsonResult(response); } catch (err) { const reason = err instanceof Error ? err.message : String(err); return jsonResult({ error: `Failed to update dashboard: ${reason}` }); } }