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

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.

~/.claude/skills/grafana-dashboards/SKILL.md

--- name: grafana-dashboards description: Designs Grafana dashboards that answer operational questions - RED layouts for services, USE layouts for resources, a top row of SLI stat… license: MIT ---

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 typeRows (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 / pipelineThroughput in vs out, backlog depth, age of oldest item, consumer lag, failure/DLQ rate
Business KPIFew large stats with comparison to last week, trends at daily resolution
Overview / fleetOne 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])))
SettingRule
Rate window$__rate_interval (accounts for scrape interval); never a fixed [1m] when the scrape interval may be 60s
Unitsreqps, percentunit (0-1 ratio), s/ms, bytes, short; set in field config
ThresholdsMatch SLO and alert thresholds so colors agree with pages
Min intervalSet on the datasource (scrape interval) so $__interval never goes below it
Legend{{route}}; table legend with max/mean for comparison panels
StackingOnly for parts of a whole (requests by status); never for latency
Y axisRatios from 0; latency often log scale when tails matter
Panel repeatRepeat 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.

  • 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:

ApproachUse 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 JSONMany similar dashboards; generate from code, commit output or render in CI
Grafana 12 Git Sync / grafanactlNewer 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 stable uid (short, readable, e.g. checkout-red).
  • Replace hardcoded datasource UIDs with ${datasource} or the provisioned UID.
  • Reset version handling 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_interval exists.
  • Averaging percentiles across instances (avg(histogram_quantile(...))) is wrong; aggregate buckets with sum by (le) first.
  • allowUiUpdates: true with 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 (last for current state, mean over 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 114 lines · 4.7 KB
#!/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.

esc