From 7bb13d1d30d3cdfd405b65b31dd67a836ebea690 Mon Sep 17 00:00:00 2001 From: "Volodymyr Shcherbinin (vovin)" Date: Mon, 27 Jul 2026 16:38:08 +0300 Subject: [PATCH 1/2] fix(grafana): pin a stable uid on every provisioned dashboard Grafana's file provider identifies a dashboard by uid, and none of the seven dashboard files carried one, so each got a random uid at provision time. That left every dashboard without a durable identity, which shows up the moment anyone edits one in the UI -- something this stack allows on purpose (allowUiUpdates: true, so students can build a panel by clicking rather than by hand-writing JSON). Saving from the UI then detached the dashboard from its file: on the next restart the edited copy was orphaned into the General folder with its provisioning link severed, and the provider built a second dashboard with the same title beside it. Measured against a clean stack: eight dashboards, two of them named "DevOps Demo", and which one a reader opens is a coin flip. With the uid pinned the provider reclaims the same dashboard instead of creating a sibling: seven dashboards, no duplicate, and the file's content wins on restart. Losing an uncommitted UI edit at restart is the correct outcome -- the file is the source of truth -- and it is the behaviour the accompanying exercise will teach. The uids read as URL paths (/d/devops-demo/, /d/analytics-history/) rather than generated hashes, so dashboard links are now stable across a rebuild and can be cited from docs and runbooks. scripts/check-dashboard-uids.sh keeps this from regressing, in the same shape as the toolchain drift gate: it rejects a missing uid, a uid shared by two files, one longer than Grafana's 40-character limit, and unparseable JSON. Without a gate the next dashboard lands without a uid and the duplication returns silently, which is how this arrived in the first place. Datasources needed no equivalent change: every dashboard resolves them through a datasource-type template variable queried by type, not by a hardcoded uid. --- .pre-commit-config.yaml | 11 ++++ .../grafana/dashboards/analytics.json | 1 + .../dashboards/devops-demo-dashboard.json | 1 + observability/grafana/dashboards/history.json | 1 + observability/grafana/dashboards/load.json | 1 + .../grafana/dashboards/monitoring-layers.json | 1 + .../grafana/dashboards/reports-ui.json | 1 + observability/grafana/dashboards/reports.json | 1 + scripts/check-dashboard-uids.sh | 55 +++++++++++++++++++ 9 files changed, 73 insertions(+) create mode 100755 scripts/check-dashboard-uids.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da8e314..6515fb0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -89,6 +89,17 @@ repos: language: system files: ^(\.mise\.toml|\.devcontainer/devcontainer\.json|services/frontend/\.nvmrc|services/.*/Dockerfile|loadgen/Dockerfile|services/backend/pyproject\.toml|.*/gradle-wrapper\.properties)$ pass_filenames: false + # Dashboard identity gate: a provisioned dashboard without a pinned uid has + # no durable identity, so a UI save detaches it and the next restart + # provisions a duplicate beside it + - repo: local + hooks: + - id: dashboard-uids + name: Grafana dashboards have stable uids + entry: bash scripts/check-dashboard-uids.sh + language: system + files: ^observability/grafana/dashboards/.*\.json$ + pass_filenames: false # Secret scanning (ADR-0011) - repo: https://github.com/gitleaks/gitleaks rev: v8.30.1 diff --git a/observability/grafana/dashboards/analytics.json b/observability/grafana/dashboards/analytics.json index aac3397..8630c93 100644 --- a/observability/grafana/dashboards/analytics.json +++ b/observability/grafana/dashboards/analytics.json @@ -1,4 +1,5 @@ { + "uid": "analytics-ingest", "annotations": { "list": [ { diff --git a/observability/grafana/dashboards/devops-demo-dashboard.json b/observability/grafana/dashboards/devops-demo-dashboard.json index 9b668fb..130d4ef 100644 --- a/observability/grafana/dashboards/devops-demo-dashboard.json +++ b/observability/grafana/dashboards/devops-demo-dashboard.json @@ -1,4 +1,5 @@ { + "uid": "devops-demo", "annotations": { "list": [{ "builtIn": 1, "datasource": "-- Grafana --", "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" }] }, "editable": true, "fiscalYearStartMonth": 1, diff --git a/observability/grafana/dashboards/history.json b/observability/grafana/dashboards/history.json index 7a5e404..9e20836 100644 --- a/observability/grafana/dashboards/history.json +++ b/observability/grafana/dashboards/history.json @@ -1,4 +1,5 @@ { + "uid": "analytics-history", "annotations": { "list": [ { diff --git a/observability/grafana/dashboards/load.json b/observability/grafana/dashboards/load.json index 5b0686f..1f449ae 100644 --- a/observability/grafana/dashboards/load.json +++ b/observability/grafana/dashboards/load.json @@ -1,4 +1,5 @@ { + "uid": "load-k6", "annotations": { "list": [{ "builtIn": 1, "datasource": "-- Grafana --", "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" }] }, "editable": true, "fiscalYearStartMonth": 1, diff --git a/observability/grafana/dashboards/monitoring-layers.json b/observability/grafana/dashboards/monitoring-layers.json index f7f3fba..d3a96be 100644 --- a/observability/grafana/dashboards/monitoring-layers.json +++ b/observability/grafana/dashboards/monitoring-layers.json @@ -1,4 +1,5 @@ { + "uid": "monitoring-layers", "annotations": { "list": [{ "builtIn": 1, "datasource": "-- Grafana --", "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" }] }, "editable": true, "fiscalYearStartMonth": 1, diff --git a/observability/grafana/dashboards/reports-ui.json b/observability/grafana/dashboards/reports-ui.json index c4d331e..862d534 100644 --- a/observability/grafana/dashboards/reports-ui.json +++ b/observability/grafana/dashboards/reports-ui.json @@ -1,4 +1,5 @@ { + "uid": "reports-ui", "annotations": { "list": [ { diff --git a/observability/grafana/dashboards/reports.json b/observability/grafana/dashboards/reports.json index a03752b..d5498e4 100644 --- a/observability/grafana/dashboards/reports.json +++ b/observability/grafana/dashboards/reports.json @@ -1,4 +1,5 @@ { + "uid": "reports-jvm", "annotations": { "list": [ { diff --git a/scripts/check-dashboard-uids.sh b/scripts/check-dashboard-uids.sh new file mode 100755 index 0000000..5fd73ef --- /dev/null +++ b/scripts/check-dashboard-uids.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Dashboard identity gate: every provisioned dashboard carries a stable uid. +# +# Grafana's file provider identifies a dashboard by uid. A file without one is +# assigned a random uid at provision time, so the dashboard has no durable +# identity: a dashboard saved from the UI detaches from its file, and the next +# restart provisions a second dashboard with the same title beside it. Pinning +# the uid in the file is what keeps one file to one dashboard, and what keeps +# /d// links stable across a rebuild. +# Runs as a prek hook locally and in CI (same config). + +set -euo pipefail + +python3 <<'PY' +import collections +import glob +import json +import os +import sys + +DASHBOARD_DIR = "observability/grafana/dashboards" +UID_MAX = 40 # Grafana rejects anything longer + +by_uid = collections.defaultdict(list) +failures = [] + +for path in sorted(glob.glob(os.path.join(DASHBOARD_DIR, "*.json"))): + try: + with open(path, encoding="utf-8") as handle: + dashboard = json.load(handle) + except (OSError, json.JSONDecodeError) as exc: + failures.append(f"{path}: cannot read as JSON: {exc}") + continue + + uid = dashboard.get("uid") + if not uid: + failures.append( + f'{path}: no "uid" -- add a stable one so this file owns exactly ' + "one dashboard (without it a UI save detaches and the next restart " + "provisions a duplicate)" + ) + elif len(uid) > UID_MAX: + failures.append(f'{path}: uid "{uid}" is longer than {UID_MAX} characters') + else: + by_uid[uid].append(path) + +for uid, paths in sorted(by_uid.items()): + if len(paths) > 1: + failures.append(f'uid "{uid}" is used by more than one file: {", ".join(paths)}') + +if failures: + for failure in failures: + print(failure, file=sys.stderr) + sys.exit(1) +PY From 76c7216c4445bb23811fee61a88b77209dc3e7b5 Mon Sep 17 00:00:00 2001 From: "Volodymyr Shcherbinin (vovin)" Date: Mon, 27 Jul 2026 16:54:43 +0300 Subject: [PATCH 2/2] fix(scripts): report malformed dashboard JSON instead of raising The gate assumed every dashboard file parses into an object with a string uid. Neither is guaranteed by JSON, so a file whose top level is an array raised AttributeError on dashboard.get, and a numeric uid raised TypeError on len(). The exit code was already correct in both cases -- the gate failed closed, so nothing malformed could pass -- but it failed with a Python traceback instead of naming the file and the problem. For a check whose only job is to explain what is wrong with a file, that is the failure mode that matters. Each shape now gets its own message: a non-object root reports the type it found, a non-string uid reports the type it found, and a null or empty uid falls in with the missing-uid case since the remedy is identical. Verified against every shape: array root, numeric uid, null uid, empty uid, missing uid, over-long uid, unparseable JSON, and two files sharing a uid all exit 1 with a named file, and the seven real dashboards still exit 0. --- scripts/check-dashboard-uids.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/check-dashboard-uids.sh b/scripts/check-dashboard-uids.sh index 5fd73ef..0723708 100755 --- a/scripts/check-dashboard-uids.sh +++ b/scripts/check-dashboard-uids.sh @@ -32,13 +32,21 @@ for path in sorted(glob.glob(os.path.join(DASHBOARD_DIR, "*.json"))): failures.append(f"{path}: cannot read as JSON: {exc}") continue + if not isinstance(dashboard, dict): + failures.append( + f"{path}: top level is {type(dashboard).__name__}, expected a JSON object" + ) + continue + uid = dashboard.get("uid") - if not uid: + if uid is None or uid == "": failures.append( f'{path}: no "uid" -- add a stable one so this file owns exactly ' "one dashboard (without it a UI save detaches and the next restart " "provisions a duplicate)" ) + elif not isinstance(uid, str): + failures.append(f'{path}: "uid" must be a string, got {type(uid).__name__}') elif len(uid) > UID_MAX: failures.append(f'{path}: uid "{uid}" is longer than {UID_MAX} characters') else: