Grafana Dashboards skill for Claude Code
Broken, where, since when: answered in 30 seconds. Claude builds dashboards around the questions on-call actually asks: SLI stats on top, rate, errors and duration by route below, then saturation and dependencies. Queries use $__rate_interval and bucket-level percentile math, datasources are referenced by variable, and the JSON is provisioned from Git and checked by a bundled linter for hardcoded UIDs, missing units and vanity panels.
- Name
grafana-dashboards- Size
- 200 lines · ≈2,200 tokens
- Files
- 2
- License
- MIT
- Updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs Grafana dashboards that answer operational questions - RED layouts for services, USE layouts for resources, a top row of SLI stat panels, template variables for datasource, environment and service, correct PromQL with $__rate_interval, units and thresholds, links to logs and traces - and keeps them as code through file provisioning, Terraform or SDK-generated JSON, with a bundled linter for dashboard JSON. Use when building or cleaning up a Grafana dashboard, reviewing dashboard JSON, provisioning dashboards and datasources, or when a dashboard is slow, cluttered or full of vanity panels. Try asking
- Build a RED dashboard for our checkout service with env and service variables
- Clean up this 60-panel Grafana dashboard so on-call can actually use it
- Provision our Grafana dashboards and Prometheus datasource from the repo
Use it when
- Creating a service, resource, queue or KPI dashboard in Grafana
- Reviewing or cleaning up exported dashboard JSON
- Setting up dashboard and datasource provisioning as code
- Dashboards are slow, cluttered, or disagree with alerts
Skip it when
- You need alert rules rather than panels — use prometheus-alerting
- The dashboard is a product analytics or BI report — use data-visualization
How to check the result
scripts/dashboard_lint.py reports no errors, every panel renders at 24 h and 7 d ranges with every variable value, and replaying a past incident window turns the top row red.
The common mistake it prevents
Committing exported JSON that still contains the instance's auto-generated datasource UID, so the dashboard shows no data on any other Grafana.
A dashboard is useful when an on-call engineer can answer “is it broken, where, and since when” in under 30 seconds from the top row, drill down without editing queries, and the JSON lives in version control with portable datasource references.
Ground rules
- Each dashboard answers one audience’s questions (service on-call, platform capacity, business KPI). If you cannot name the question a panel answers, delete the panel.
- Top row: 3-6 stat panels for the service’s SLIs with thresholds matching the SLO.
- Every time series has a unit, a meaningful legend (
{{route}}, not the full label set), and a description explaining what “bad” looks like. - Reference datasources by variable (
${datasource}) or stable provisioned UID, never by an instance-specific auto-generated UID. - Dashboards in production are provisioned from Git; UI edits are for drafting, then exported and committed.
Workflow
- [ ] 1. Identify audience, questions, and the signals available
- [ ] 2. Choose the layout (RED, USE, or KPI) and sketch rows
- [ ] 3. Define variables (datasource, env, cluster, service)
- [ ] 4. Write queries with correct rate windows, units, thresholds
- [ ] 5. Add links: drill-down dashboards, logs, traces, runbooks
- [ ] 6. Store as code and provision; lint the JSON
- [ ] 7. Verify load time and test with a real incident timeline
1-2. Layouts
| Dashboard type | Rows (top to bottom) |
|---|---|
| Service (RED) | SLI stats -> Rate, Errors, Duration by route -> saturation (CPU, memory, pool usage) -> dependencies (DB, cache, downstream latency) -> deploy annotations |
| Resource (USE) | Utilization, Saturation, Errors per resource: CPU, memory, disk, network, per node/pool |
| Queue / pipeline | Throughput in vs out, backlog depth, age of oldest item, consumer lag, failure/DLQ rate |
| Business KPI | Few large stats with comparison to last week, trends at daily resolution |
| Overview / fleet | One row per service: status stat + error ratio + p99, linking to service dashboards |
Avoid: pie charts of status codes, gauges for rates, more than ~25 panels on one dashboard, uptime counters, “requests total since start”, panels that duplicate alerts without context.
3. Variables
"templating": { "list": [
{ "name": "datasource", "type": "datasource", "query": "prometheus", "current": {} },
{ "name": "env", "type": "query", "datasource": { "type": "prometheus", "uid": "${datasource}" },
"query": "label_values(up{job=~\".+\"}, env)", "refresh": 2 },
{ "name": "service", "type": "query", "datasource": { "type": "prometheus", "uid": "${datasource}" },
"query": "label_values(http_requests_total{env=\"$env\"}, service)",
"refresh": 2, "includeAll": true, "multi": true }
]}
refresh: 2(on time range change) keeps lists current;1(on load) is cheaper.- Chain variables (
env->service) so options stay relevant. - With
multi/includeAll, use=~"$service"in queries; set a custom all value (.*) only if the label list is huge.
4. Queries and panel settings
# Rate by route
sum by (route) (rate(http_requests_total{env="$env", service=~"$service"}[$__rate_interval]))
# Error ratio
sum(rate(http_requests_total{env="$env", service=~"$service", code=~"5.."}[$__rate_interval]))
/ sum(rate(http_requests_total{env="$env", service=~"$service"}[$__rate_interval]))
# p50/p95/p99 latency (one query per quantile, legend "p99")
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{env="$env", service=~"$service"}[$__rate_interval])))
| Setting | Rule |
|---|---|
| Rate window | $__rate_interval (accounts for scrape interval); never a fixed [1m] when the scrape interval may be 60s |
| Units | reqps, percentunit (0-1 ratio), s/ms, bytes, short; set in field config |
| Thresholds | Match SLO and alert thresholds so colors agree with pages |
| Min interval | Set on the datasource (scrape interval) so $__interval never goes below it |
| Legend | {{route}}; table legend with max/mean for comparison panels |
| Stacking | Only for parts of a whole (requests by status); never for latency |
| Y axis | Ratios from 0; latency often log scale when tails matter |
| Panel repeat | Repeat rows by $service instead of copy-pasting panels |
Heavy queries: move to recording rules (see prometheus-alerting) and query the recorded series; keep each panel under ~1 s at a 24 h range.
5. Links and context
- Data links from series to the logs explorer filtered by service and time, and to traces (exemplars on latency panels if the datasource supports them).
- Dashboard links (by tag) to drill-down dashboards, keeping time range and variables.
- Annotations for deploys (from a deploy-events metric, Loki query, or CI posting to the annotations API) and incidents.
- Text panel or description with runbook and alert links, short.
6. As code and provisioning
Pick one source of truth:
| Approach | Use when |
|---|---|
| File provisioning (JSON in Git, mounted into Grafana) | Self-hosted Grafana, simple setups |
Terraform grafana provider (grafana_dashboard, grafana_folder, grafana_data_source) | Grafana Cloud or many instances, already on Terraform |
| Grafonnet / Grafana Foundation SDK generating JSON | Many similar dashboards; generate from code, commit output or render in CI |
| Grafana 12 Git Sync / grafanactl | Newer Git-backed workflows; confirm feature status for your Grafana version first |
File provisioning (/etc/grafana/provisioning/dashboards/services.yaml):
apiVersion: 1
providers:
- name: services
orgId: 1
folder: Services
type: file
disableDeletion: true
allowUiUpdates: false
updateIntervalSeconds: 60
options:
path: /var/lib/grafana/dashboards/services
foldersFromFilesStructure: false
Datasource with a fixed UID (/etc/grafana/provisioning/datasources/prometheus.yaml):
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus-main
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
jsonData: { timeInterval: 30s }
Before committing exported JSON:
- Remove
id; keep a stableuid(short, readable, e.g.checkout-red). - Replace hardcoded datasource UIDs with
${datasource}or the provisioned UID. - Reset
versionhandling to your tool (Terraform and provisioning overwrite it). - Set sane defaults:
time.from: now-6h,refresh: 1m(not 5s), timezone browser/UTC.
Lint the JSON:
python3 scripts/dashboard_lint.py dashboards/services/*.json
python3 scripts/dashboard_lint.py --uid=prometheus-main dashboards/services/*.json # allow a provisioned uid
scripts/dashboard_lint.py flags: hardcoded datasource UIDs, missing units, panels
without titles or descriptions, fixed rate windows instead of $__rate_interval,
irate in panels, refresh faster than 30 s, more than 25 panels, missing uid,
lingering numeric id, and pie/gauge panels on rate queries. Exits 1 on errors.
7. Verify
- Load the dashboard at 24 h and 7 d ranges: every panel renders, none above ~2 s (query inspector shows timing).
- Switch every variable value, including All; no “No data” surprises.
- Replay a past incident window: the top row turns red within the first minutes and the drill-down points at the right component.
- Provisioned dashboard reloads after a Git change; UI shows it as provisioned.
Gotchas
rate(x[$__interval])breaks at short ranges when the interval is below twice the scrape interval; this is why$__rate_intervalexists.- Averaging percentiles across instances (
avg(histogram_quantile(...))) is wrong; aggregate buckets withsum by (le)first. allowUiUpdates: truewith file provisioning lets UI saves diverge silently from Git until the next restart overwrites them.- Dashboard refresh of 5-10 s on a shared TV with many panels can overload Prometheus; use 1m and recording rules.
- Thresholds on stat panels use the reduced value (last, mean); pick the reducer
deliberately (
lastfor current state,meanover range for reports). - Template variable queries against high-cardinality labels make the dashboard slow to
open; scope them with a selector or use
label_values(metric, label)on a small metric.
Output
**Dashboard:** Checkout / RED (uid checkout-red), folder Services
**Rows:** SLI stats (availability, p99, error budget) | RED by route | saturation | deps | deploys
**Variables:** datasource, env, service (multi)
**Queries:** $__rate_interval everywhere; 3 heavy panels moved to recording rules
**As code:** dashboards/services/checkout-red.json via file provisioning; lint 0 errors
**Checks:** 24h load 1.3 s; incident 2026-02-11 replay shows error stat red at +2 min Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/dashboard_lint.py
#!/usr/bin/env python3
"""Lint Grafana dashboard JSON for portability and usability problems.
Usage: python3 scripts/dashboard_lint.py dashboard.json [more.json ...]
Accepts raw dashboard JSON or the API export shape {"dashboard": {...}}.
Exit code 1 when any ERROR finding exists.
"""
import json
import re
import sys
MAX_PANELS = 25
FIXED_RATE = re.compile(r"\b(rate|increase)\([^()]*\[(\d+[smhd])\]")
UID_VAR = re.compile(r"^\$\{?\w+\}?$")
def iter_panels(panels):
for p in panels or []:
yield p
# collapsed rows keep children in "panels"
if p.get("type") == "row" and p.get("panels"):
yield from iter_panels(p["panels"])
def refresh_seconds(value):
m = re.fullmatch(r"(\d+)([smhd])", str(value or ""))
if not m:
return None
return int(m.group(1)) * {"s": 1, "m": 60, "h": 3600, "d": 86400}[m.group(2)]
def ds_problem(ds, provisioned):
"""Return a message if a datasource reference is hardcoded."""
if ds is None or isinstance(ds, str):
if isinstance(ds, str) and not UID_VAR.match(ds) and ds not in provisioned:
return f"legacy string datasource '{ds}'"
return None
uid = ds.get("uid")
if uid and not UID_VAR.match(str(uid)) and uid not in provisioned and uid not in ("-- Grafana --", "grafana"):
return f"hardcoded datasource uid '{uid}' (use ${{datasource}} or a provisioned uid)"
return None
def lint(path, provisioned):
data = json.load(open(path, encoding="utf-8"))
dash = data.get("dashboard", data)
out = []
add = lambda lvl, where, msg: out.append((lvl, where, msg))
if not dash.get("uid"):
add("ERROR", "dashboard", "missing uid: links and provisioning break")
if isinstance(dash.get("id"), int):
add("WARN", "dashboard", "numeric id present; remove it before committing")
r = refresh_seconds(dash.get("refresh"))
if r is not None and r < 30:
add("WARN", "dashboard", f"refresh {dash.get('refresh')} is under 30s; heavy load on the datasource")
variables = [v.get("name") for v in dash.get("templating", {}).get("list", [])]
if "datasource" not in variables and not provisioned:
add("WARN", "dashboard", "no datasource variable; dashboard is tied to one instance")
panels = [p for p in iter_panels(dash.get("panels")) if p.get("type") != "row"]
if len(panels) > MAX_PANELS:
add("WARN", "dashboard", f"{len(panels)} panels (> {MAX_PANELS}); split into overview + drill-down")
for p in panels:
title = p.get("title") or ""
where = f"panel '{title or p.get('id', '?')}'"
ptype = p.get("type", "")
if not title.strip():
add("ERROR", where, "panel has no title")
if ptype in ("text", "news", "dashlist"):
continue
if not (p.get("description") or "").strip():
add("WARN", where, "no description: say what bad looks like")
msg = ds_problem(p.get("datasource"), provisioned)
if msg:
add("ERROR", where, msg)
unit = (p.get("fieldConfig", {}).get("defaults", {}) or {}).get("unit")
if ptype in ("timeseries", "stat", "gauge", "bargauge") and not unit:
add("WARN", where, "no unit set")
for t in p.get("targets") or []:
expr = t.get("expr") or ""
msg = ds_problem(t.get("datasource"), provisioned)
if msg:
add("ERROR", where, f"target {t.get('refId', '?')}: {msg}")
if FIXED_RATE.search(expr):
add("WARN", where, f"fixed rate window in '{expr[:60]}...'; use [$__rate_interval]")
if "irate(" in expr:
add("WARN", where, "irate() hides spikes between points; prefer rate()")
if ptype in ("piechart", "gauge") and "rate(" in expr:
add("WARN", where, f"{ptype} of a rate loses time context; use a time series")
if re.search(r"avg\s*(by\s*\([^)]*\)\s*)?\(\s*histogram_quantile", expr):
add("ERROR", where, "averaging percentiles; aggregate buckets with sum by (le) first")
return out
def main(argv):
args = [a for a in argv[1:] if not a.startswith("--uid=")]
provisioned = {a.split("=", 1)[1] for a in argv[1:] if a.startswith("--uid=")}
if not args:
print(__doc__ + "\nOptional: --uid=<provisioned-datasource-uid> (repeatable) to allow fixed uids.")
return 2
errors = total = 0
for path in args:
for lvl, where, msg in lint(path, provisioned):
total += 1
errors += lvl == "ERROR"
print(f"{lvl:5} {path}: {where}: {msg}")
print(f"\n{total} finding(s), {errors} error(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.