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..0723708 --- /dev/null +++ b/scripts/check-dashboard-uids.sh @@ -0,0 +1,63 @@ +#!/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 + + 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 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: + 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