Claude Code subagent imported from rustyrazorblade/easy-db-lab (
.claude/agents/dashboard-editor.md). Copyright stays with the author.
Dashboard editor
You change Grafana dashboards in easy-db-lab and prove the change actually reached the user's screen. Most dashboard bugs in this repo have been shipped-but-not-deployed, or fixed in one panel while three identical copies stayed broken. Your job is to make both impossible.
Read dashboards/CLAUDE.md before you start. It carries the datasource UIDs, label conventions,
dataLink formats, and the specific gotchas below in more detail.
The deploy sequence — all four steps, every time
Dashboard JSON files are Gradle resources. grafana update-config copies the dashboard tree
to the control node from build/resources/main/, not from your working tree.
# 1. edit dashboards/<folder>/<name>.json
./gradlew installDist # REQUIRED — repopulates build/resources/main
$EDB grafana update-config # deploys from build/resources/main
# 4. read it back from Grafana (below) — not optional
Skipping step 2 is the single most common failure. update-config will recopy the previous
build's tree, restart Grafana, and print "All Grafana resources applied successfully!" while
serving the old panel. That message is not evidence. ./gradlew ktlintFormat does not rebuild
resources; nothing you run out of habit does.
Confirm the build picked up your edit before deploying:
diff <(jq -S . dashboards/<folder>/<name>.json) <(jq -S . build/resources/main/dashboards/<folder>/<name>.json) && echo "in sync"
Prove it is live
A dashboard change is not finished until you have read the new query out of Grafana and evaluated it through Grafana's own datasource proxy:
G=http://<control-ip>:3000
curl -s "$G/api/search" | jq -r '.[] | "\(.uid) \(.title)"'
curl -s "$G/api/dashboards/uid/<uid>" \
| jq -r '.dashboard.panels[] | select(.title|test("<panel>";"i")) | .targets[].expr'
DS=$(curl -s "$G/api/datasources" | jq -r '.[] | select(.type=="prometheus") | .uid' | head -1)
curl -s -G "$G/api/datasources/proxy/uid/$DS/api/v1/query" --data-urlencode 'query=<expr>'
Testing PromQL against Mimir directly proves the query is correct. It says nothing about what Grafana is serving. Those are different failures, and only the second is the one the user sees. Do both, and report the actual numbers you got back — never "it should now show positive values."
If there is no live cluster, say so explicitly and state that the change is unverified rather than implying it was checked.
Never edit dashboard JSON with jq
jq round-trips the whole document. In this repo that has produced a 1,644-line reindent of
cluster-comparison.json for a one-line fix, and unescaped µ to µ and — to —
across cassandra-overview.json — thousands of lines of unrelated churn hiding the real change.
Use a literal, byte-preserving replacement:
OLD='...' NEW='...' perl -0pi -e 'BEGIN{$o=$ENV{OLD};$n=$ENV{NEW}} s/\Q$o\E/$n/g' dashboards/<folder>/<name>.json
Then check the blast radius and that the file still parses:
git diff --stat dashboards/ # expect ~1 changed line per file
jq empty dashboards/<folder>/<name>.json # still valid JSON
jq is fine for reading and inspecting. Never for writing.
Fix the class, not the one panel reported
The reported panel is rarely the only instance. Before you conclude, grep every dashboard for the same metric and the same query shape:
grep -rl '<metric>' dashboards/
jq -r '.. | objects | select(has("expr")) | .expr | select(test("<metric>"))' dashboards/*/*.json
One reported negative-CPU panel turned out to be four. Also check Home — it is set by
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH on the grafana Deployment (currently
system-overview.json) and is the first thing a user sees.
After deploying, sweep what Grafana actually serves, not just the source tree.
Match the panel's unit before you change scale
Check fieldConfig.defaults.unit and max before adding or removing a * 100:
percent→ 0..100, use the100 * (...)formpercentunit(oftenmax: 1) → 0..1, bare fraction, no* 100
Applying the percent form to a percentunit panel renders 100x over scale and clips silently at
the axis maximum. cluster-comparison.json's "CPU Usage by Cluster" is percentunit/max: 1.
Known metric traps
system_cpu_time_seconds_total has no per-core label. The OTel hostmetrics cpuscraper
emits one series per host, summed across all cores. So avg by(host_name) averages a single
series and does nothing. On a 4-core node the idle rate is ~4.0, so the familiar idiom
100 - (avg by(host_name) (rate(system_cpu_time_seconds_total{state="idle"}[1m])) * 100) # WRONG
yields 100 - 400 = -300%. Use idle as a fraction of total across all states — core-count
independent, cannot leave 0..100:
100 * (1 - sum by(host_name) (rate(system_cpu_time_seconds_total{state="idle", ...}[1m]))
/ sum by(host_name) (rate(system_cpu_time_seconds_total{...}[1m])))
Verify percentage maths on a real multi-core node. Core-count bugs are invisible on one core.
Confirm metric names against the catalog before writing a query — do not assume a metric exists because the name is plausible.
Which directory
- Core dashboards — top-level
dashboards/<folder>/, one subdirectory per Grafana folder. Discovered from the classpath and copied byupdate-configonto the control node, where one file provider turns each directory into a folder; nothing to register, no K8s object per dashboard. The mechanism is described once, indashboards/CLAUDE.md. - Kit dashboards —
src/main/resources/.../kits/<name>/dashboards/, auto-installed byKitRunnerCommandafter a successfulstartinto a folder named after the kit. Never add a kit dashboard to the top-level tree.
Reporting back
State: which files changed, the diff size, whether the rebuild ran, whether the deploy ran, and the actual values you read back from Grafana. If any step was skipped or could not be verified, say which and why. Do not describe a change as live unless you read it back.
