Last active
August 28, 2025 07:23
-
-
Save grenade/b72490f341eae2cfafa3b81f6ebf6d7b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
GRAFANA_URL=https://grafana.example.com | |
GRAFANA_TOKEN=glsa_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_a9999999 | |
REPO_DIR=/srv/backups/grafana-dashboards | |
[email protected]:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.git | |
BRANCH=main |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
set -euo pipefail | |
# --- Required env vars --- | |
# GRAFANA_URL="https://grafana.example.com" | |
# GRAFANA_TOKEN="grafana_api_token_with_reader_rights" | |
# REPO_DIR="/srv/backups/grafana-dashboards" # the checked-out git repo (origin already set) | |
# Optional: BRANCH="main" | |
: "${GRAFANA_URL:?GRAFANA_URL is required}" | |
: "${GRAFANA_TOKEN:?GRAFANA_TOKEN is required}" | |
: "${REPO_DIR:?REPO_DIR is required}" | |
BRANCH="${BRANCH:-main}" | |
cd "$REPO_DIR" | |
# Ensure git is set up | |
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "REPO_DIR is not a git repo"; exit 1; } | |
git checkout "$BRANCH" | |
mkdir -p dashboards | |
mkdir -p meta | |
auth_hdr=( -H "Authorization: Bearer ${GRAFANA_TOKEN}" ) | |
common_curl=( -sS -f "${auth_hdr[@]}" ) | |
slugify() { | |
# lowercase, spaces -> dashes, strip non alnum/- chars, trim dashes | |
echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[[:space:]]+/-/g; s/[^a-z0-9-]//g; s/^-+|-+$//g' | |
} | |
echo "Fetching folders..." | |
folders_json="$(curl "${common_curl[@]}" "${GRAFANA_URL}/api/folders?limit=5000")" | |
echo "$folders_json" | jq -S '.' > meta/folders.json | |
echo "Fetching dashboards list..." | |
# type=dash-db returns the “classic” dashboards in the UI | |
search_json="$(curl "${common_curl[@]}" "${GRAFANA_URL}/api/search?type=dash-db&limit=5000")" | |
echo "$search_json" | jq -S '.' > meta/search.json | |
# Build folderId -> folderTitle map (with General folder as id 0) | |
declare -A FID2TITLE | |
FID2TITLE[0]="General" | |
while IFS=$'\t' read -r id title; do | |
FID2TITLE["$id"]="$title" | |
done < <(echo "$folders_json" | jq -r '.[] | [.id, .title] | @tsv') | |
echo "Exporting dashboards..." | |
# For each dashboard, fetch the full definition by UID and save a normalized JSON | |
echo "$search_json" | jq -r '.[] | select(.type=="dash-db") | [.uid, .title, (.folderId // 0)] | @tsv' | \ | |
while IFS=$'\t' read -r uid title folder_id; do | |
folder="${FID2TITLE[$folder_id]:-General}" | |
safe_folder="$(slugify "$folder")" | |
safe_title="$(slugify "$title")" | |
out_dir="dashboards/${safe_folder}" | |
mkdir -p "$out_dir" | |
full="$(curl "${common_curl[@]}" "${GRAFANA_URL}/api/dashboards/uid/${uid}")" | |
# Extract the “dashboard” object and normalize for clean diffs: | |
# - force id=null (server-managed) | |
# - remove fields that churn (iteration, version) – optional, comment out if you prefer to keep version history | |
normalized="$(echo "$full" | jq -S ' | |
.dashboard | |
| .id = null | |
| del(.iteration) | |
| del(.version) | |
')" | |
# Save with UID in filename to avoid collisions on renamed dashboards | |
out_file="${out_dir}/${safe_title}-${uid}.json" | |
echo "$normalized" > "$out_file" | |
# also save a tiny meta file with original folder & title (handy during restores) | |
printf '%s\n' "$full" | jq -S '{uid: .meta.uid, title: .meta.slug, folderTitle: .meta.folderTitle, url: .meta.url}' \ | |
> "${out_dir}/${safe_title}-${uid}.meta.json" | |
echo " saved: ${out_file}" | |
done | |
# Commit if there are changes | |
if ! git diff --quiet || ! git diff --cached --quiet; then | |
git add dashboards meta | |
git commit -m "Grafana dashboards backup: $(date -Iseconds)" | |
git push origin "$BRANCH" | |
else | |
echo "No changes." | |
fi |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[Unit] | |
Description=Backup Grafana dashboards to Git | |
[Service] | |
Type=oneshot | |
Environment=GRAFANA_URL=https://grafana.example.com | |
Environment=GRAFANA_TOKEN=glsa_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_a9999999 | |
Environment=REPO_DIR=/srv/backups/grafana-dashboards | |
Environment=REPO_URL[email protected]:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.git | |
Environment=BRANCH=main | |
EnvironmentFile=/etc/backup-grafana-dashboards.env | |
ExecStartPre=curl \ | |
--fail \ | |
--location \ | |
--output /usr/local/bin/backup-grafana-dashboards.sh \ | |
--silent \ | |
--url https://gist.githubusercontent.com/grenade/b72490f341eae2cfafa3b81f6ebf6d7b/raw/backup-grafana-dashboards.sh | |
ExecStartPre=chmod +x /usr/local/bin/backup-grafana-dashboards.sh | |
ExecStart=/usr/local/bin/backup-grafana-dashboards.sh |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[Unit] | |
Description=Nightly Grafana dashboard backup | |
[Timer] | |
OnCalendar=*-*-* 02:30:00 | |
RandomizedDelaySec=10m | |
Persistent=true | |
[Install] | |
WantedBy=timers.target |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
#usage: curl -sL https://gist.github.com/grenade/b72490f341eae2cfafa3b81f6ebf6d7b/raw/install-dashboard-backup-units.sh | bash | |
if [ -f /etc/systemd/system/grafana-dashboards-backup.service ] && systemctl is-active --quiet grafana-dashboards-backup; then | |
sudo systemctl stop grafana-dashboards-backup | |
fi | |
if [ ! -f /etc/backup-grafana-dashboards.env ]; then | |
sudo curl \ | |
--fail \ | |
--location \ | |
--output /etc/backup-grafana-dashboards.env \ | |
--silent \ | |
--url https://gist.githubusercontent.com/grenade/b72490f341eae2cfafa3b81f6ebf6d7b/raw/backup-grafana-dashboards.env.example | |
fi | |
for unit in service timer; do | |
sudo curl \ | |
--fail \ | |
--location \ | |
--output /etc/systemd/system/grafana-dashboards-backup.${unit} \ | |
--silent \ | |
--url https://gist.githubusercontent.com/grenade/b72490f341eae2cfafa3b81f6ebf6d7b/raw/grafana-dashboards-backup.${unit} | |
done | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
set -euo pipefail | |
# --- Required env vars --- | |
# GRAFANA_URL="https://grafana.example.com" | |
# GRAFANA_TOKEN="grafana_api_token_with_admin_or_editor_rights" | |
# REPO_DIR="/srv/backups/grafana-dashboards" | |
# Optional: BRANCH="main" | |
: "${GRAFANA_URL:?GRAFANA_URL is required}" | |
: "${GRAFANA_TOKEN:?GRAFANA_TOKEN is required}" | |
: "${REPO_DIR:?REPO_DIR is required}" | |
BRANCH="${BRANCH:-main}" | |
cd "$REPO_DIR" | |
git checkout "$BRANCH" | |
git pull --ff-only | |
auth_hdr=( -H "Authorization: Bearer ${GRAFANA_TOKEN}" -H "Content-Type: application/json" ) | |
common_curl=( -sS -f "${auth_hdr[@]}" ) | |
slugify() { | |
echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[[:space:]]+/-/g; s/[^a-z0-9-]//g; s/^-+|-+$//g' | |
} | |
echo "Loading existing folders..." | |
folders_now="$(curl "${common_curl[@]}" "${GRAFANA_URL}/api/folders?limit=5000")" | |
get_folder_id_by_title() { | |
local title="$1" | |
if [[ "$title" == "General" ]]; then | |
echo 0; return | |
fi | |
echo "$folders_now" | jq -r --arg t "$title" '.[] | select(.title==$t) | .id' | head -n1 | |
} | |
create_folder_if_missing() { | |
local title="$1" | |
[[ "$title" == "General" ]] && { echo 0; return; } | |
local id | |
id="$(get_folder_id_by_title "$title")" | |
if [[ -z "${id:-}" ]]; then | |
echo "Creating folder: $title" | |
resp="$(curl "${common_curl[@]}" -X POST "${GRAFANA_URL}/api/folders" \ | |
--data "$(jq -n --arg t "$title" '{title:$t}')" )" | |
folders_now="$(curl "${common_curl[@]}" "${GRAFANA_URL}/api/folders?limit=5000")" | |
id="$(echo "$resp" | jq -r '.id')" | |
fi | |
echo "$id" | |
} | |
shopt -s nullglob | |
for json_file in dashboards/*/*.json; do | |
folder_dir="$(basename "$(dirname "$json_file")")" | |
# Recover original folder name from meta if present (more accurate than slug) | |
meta_file="${json_file%.json}.meta.json" | |
if [[ -f "$meta_file" ]]; then | |
folder_title="$(jq -r '.folderTitle // "General"' "$meta_file")" | |
else | |
# fall back to deslugging folder_dir best-effort | |
folder_title="$(echo "$folder_dir" | sed -E 's/-/ /g')" | |
[[ "$folder_title" =~ ^general$ ]] && folder_title="General" | |
fi | |
folder_id="$(create_folder_if_missing "$folder_title")" | |
dashboard_json="$(cat "$json_file")" | |
payload="$(jq -n \ | |
--argjson dash "$dashboard_json" \ | |
--argjson fid "$folder_id" \ | |
'{ | |
dashboard: $dash, | |
folderId: ($fid|tonumber), | |
overwrite: true, | |
message: "restore" | |
}' | |
)" | |
echo "Restoring $(basename "$json_file") -> folder [$folder_title] (id $folder_id)" | |
curl "${common_curl[@]}" -X POST "${GRAFANA_URL}/api/dashboards/db" --data "$payload" >/dev/null | |
done | |
echo "Restore complete." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment