Work version
This commit is contained in:
commit
692fa563e1
13 changed files with 2301 additions and 0 deletions
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Binaries
|
||||||
|
grafana_gen
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Credentials
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Output of the go coverage tool
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Config with sensitive data
|
||||||
|
config.yaml
|
||||||
|
config.yml
|
||||||
|
*.local.yaml
|
||||||
|
*.local.yml
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
344
README.md
Normal file
344
README.md
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
# grafana_gen — Руководство пользователя
|
||||||
|
|
||||||
|
Утилита автоматически генерирует дашборды Grafana на основе данных из PostgreSQL. При каждом запуске она читает список клиентов и их ресурсов из БД, сравнивает с предыдущим состоянием и пересоздаёт единый дашборд только при наличии изменений.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
```
|
||||||
|
PostgreSQL (test_info)
|
||||||
|
↓
|
||||||
|
Список клиентов и ресурсов (SID, domain_name, aliases)
|
||||||
|
↓
|
||||||
|
Проверка изменений (state.json)
|
||||||
|
↓ нет изменений → выход
|
||||||
|
Загрузка шаблона (dashboard_template.json из Git)
|
||||||
|
↓
|
||||||
|
Генерация JSON дашборда
|
||||||
|
↓
|
||||||
|
Grafana API → создание/обновление дашборда
|
||||||
|
```
|
||||||
|
|
||||||
|
На каждый запуск создаётся **один дашборд** со всеми клиентами. Каждый клиент отображается как свёрнутая строка (collapsed row), внутри которой — панели по каждому ресурсу.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Go 1.21+
|
||||||
|
- PostgreSQL с базой `test_info`
|
||||||
|
- Grafana с API-доступом (Service Account Token или Legacy API Key)
|
||||||
|
- Git (для загрузки шаблонов)
|
||||||
|
- Доступ к Gitea-репозиторию с шаблоном
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Установка и запуск
|
||||||
|
|
||||||
|
### Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <репозиторий утилиты>
|
||||||
|
cd grafana_gen
|
||||||
|
go build -o grafana_gen .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Первый запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./grafana_gen \
|
||||||
|
-grafana-url https://grafana.example.com \
|
||||||
|
-grafana-api-key glsa_xxxxxxxxxxxx \
|
||||||
|
-db-user waf_reader \
|
||||||
|
-db-password secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### Через .env файл (рекомендуется)
|
||||||
|
|
||||||
|
Создайте файл `/etc/grafana_gen/grafana_gen.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GRAFANA_URL=https://grafana.example.com
|
||||||
|
GRAFANA_API_KEY=glsa_xxxxxxxxxxxx
|
||||||
|
DB_USER=waf_reader
|
||||||
|
DB_PASSWORD=secret
|
||||||
|
GIT_TOKEN=your-gitea-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем просто:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./grafana_gen
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
### Способы задать параметры (в порядке приоритета)
|
||||||
|
|
||||||
|
| Приоритет | Способ | Пример |
|
||||||
|
|-----------|--------|--------|
|
||||||
|
| 1 | Флаг командной строки | `-grafana-url https://...` |
|
||||||
|
| 2 | Переменная окружения | `export GRAFANA_URL=https://...` |
|
||||||
|
| 3 | `.env` файл | `GRAFANA_URL=https://...` |
|
||||||
|
| 4 | Константа в `config.go` | `DefaultGrafanaURL = "https://..."` |
|
||||||
|
|
||||||
|
### Пути поиска .env файла
|
||||||
|
|
||||||
|
Утилита ищет `.env` файл в следующем порядке:
|
||||||
|
|
||||||
|
1. Путь из переменной `GRAFANA_GEN_ENV_FILE`
|
||||||
|
2. `/etc/grafana_gen/grafana_gen.env`
|
||||||
|
3. `./grafana_gen.env` (рядом с бинарником)
|
||||||
|
4. `./.env`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Флаги командной строки
|
||||||
|
|
||||||
|
### База данных
|
||||||
|
|
||||||
|
| Флаг | Переменная окружения | По умолчанию | Описание |
|
||||||
|
|------|---------------------|--------------|----------|
|
||||||
|
| `-db-host` | — | `10.100.10.8` | Хост PostgreSQL |
|
||||||
|
| `-db-port` | — | `5432` | Порт PostgreSQL |
|
||||||
|
| `-db-user` | `DB_USER` | — | Пользователь БД (**обязательно**) |
|
||||||
|
| `-db-password` | `DB_PASSWORD` | — | Пароль БД (**обязательно**) |
|
||||||
|
| `-db-name` | — | `test_info` | Имя базы данных |
|
||||||
|
| `-use-manual` | — | `false` | Использовать таблицу `manual_info` вместо `sp_info` |
|
||||||
|
|
||||||
|
### Grafana
|
||||||
|
|
||||||
|
| Флаг | Переменная окружения | По умолчанию | Описание |
|
||||||
|
|------|---------------------|--------------|----------|
|
||||||
|
| `-grafana-url` | `GRAFANA_URL` | — | URL Grafana (**обязательно**) |
|
||||||
|
| `-grafana-api-key` | `GRAFANA_API_KEY` | — | API-ключ Grafana (**обязательно**) |
|
||||||
|
| `-grafana-folder` | `GRAFANA_FOLDER` | `WAF - Auto Generated` | Папка в Grafana для дашборда |
|
||||||
|
| `-dashboard-title` | `DASHBOARD_TITLE` | `PT AF Nodes` | Название дашборда |
|
||||||
|
|
||||||
|
### Git / шаблоны
|
||||||
|
|
||||||
|
| Флаг | Переменная окружения | По умолчанию | Описание |
|
||||||
|
|------|---------------------|--------------|----------|
|
||||||
|
| `-git-token` | `GIT_TOKEN` | — | Токен доступа к Gitea |
|
||||||
|
| `-templates-repo` | — | *(см. config.go)* | URL Git-репозитория с шаблонами |
|
||||||
|
| `-templates-branch` | — | `master` | Ветка репозитория |
|
||||||
|
| `-templates-path` | — | `/etc/grafana_gen/templates` | Локальный путь для шаблонов |
|
||||||
|
| `-skip-git-pull` | — | `false` | Не обновлять шаблоны из Git |
|
||||||
|
|
||||||
|
### Управление запуском
|
||||||
|
|
||||||
|
| Флаг | По умолчанию | Описание |
|
||||||
|
|------|--------------|----------|
|
||||||
|
| `-dry-run` | `false` | Сгенерировать JSON, но не отправлять в Grafana |
|
||||||
|
| `-force` | `false` | Принудительная регенерация даже без изменений |
|
||||||
|
| `-state-file` | `/var/lib/grafana_gen/state.json` | Путь к файлу состояния |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Приоритет параметров
|
||||||
|
|
||||||
|
Пример: если одновременно задан флаг `-grafana-api-key`, переменная `GRAFANA_API_KEY` и значение в `.env` — используется **флаг командной строки** как наиболее приоритетный.
|
||||||
|
|
||||||
|
```
|
||||||
|
Флаг CLI > Переменная окружения > .env файл > константа в config.go
|
||||||
|
```
|
||||||
|
|
||||||
|
Это позволяет безопасно хранить секреты в `.env` и при необходимости переопределять их на лету без изменения файлов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State-файл
|
||||||
|
|
||||||
|
Утилита сохраняет состояние после каждого успешного запуска в JSON-файл (по умолчанию `/var/lib/grafana_gen/state.json`).
|
||||||
|
|
||||||
|
При следующем запуске сравниваются:
|
||||||
|
|
||||||
|
- Коммит шаблона в Git
|
||||||
|
- Версия шаблона (`version` в `dashboard_template.json`)
|
||||||
|
- SHA-256 хэш данных из БД (список клиентов, доменов, SID)
|
||||||
|
- Количество клиентов и доменов
|
||||||
|
|
||||||
|
Если ничего не изменилось — дашборд не пересоздаётся, утилита завершается с кодом 0.
|
||||||
|
|
||||||
|
```
|
||||||
|
=== No Changes Detected ===
|
||||||
|
No changes in templates or database since last run.
|
||||||
|
Skipping dashboard generation.
|
||||||
|
|
||||||
|
Use -force flag to regenerate anyway.
|
||||||
|
```
|
||||||
|
|
||||||
|
Если изменения есть — выводится подробный отчёт:
|
||||||
|
|
||||||
|
```
|
||||||
|
=== Changes Detected ===
|
||||||
|
Changes detected:
|
||||||
|
Template commit changed: abc12345 -> def67890
|
||||||
|
Database content changed
|
||||||
|
- New SIDs: [SID_001, SID_002]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Режим dry-run
|
||||||
|
|
||||||
|
Позволяет проверить что будет сгенерировано без отправки в Grafana:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./grafana_gen -dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
В лог выводится превью JSON дашборда и итоговая статистика:
|
||||||
|
|
||||||
|
```
|
||||||
|
DRY RUN: Dashboard generated but not sent to Grafana
|
||||||
|
=== Summary ===
|
||||||
|
Clients: 12
|
||||||
|
Skipped: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Запуск через cron
|
||||||
|
|
||||||
|
Рекомендуемый вариант — запуск каждые 15 минут:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
*/15 * * * * /usr/local/bin/grafana_gen >> /var/log/grafana_gen.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Утилита сама определяет нужно ли обновлять дашборд — частые запуски без изменений завершаются мгновенно.
|
||||||
|
|
||||||
|
### Systemd timer (альтернатива)
|
||||||
|
|
||||||
|
`/etc/systemd/system/grafana-gen.service`:
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Grafana Dashboard Generator
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
EnvironmentFile=/etc/grafana_gen/grafana_gen.env
|
||||||
|
ExecStart=/usr/local/bin/grafana_gen
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/systemd/system/grafana-gen.timer`:
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Run grafana-gen every 15 minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=15min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl enable --now grafana-gen.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Типичные сценарии
|
||||||
|
|
||||||
|
### Первое развёртывание
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Создать .env
|
||||||
|
cp grafana_gen.env.example /etc/grafana_gen/grafana_gen.env
|
||||||
|
vim /etc/grafana_gen/grafana_gen.env
|
||||||
|
|
||||||
|
# 2. Проверить без отправки
|
||||||
|
./grafana_gen -dry-run
|
||||||
|
|
||||||
|
# 3. Создать дашборд
|
||||||
|
./grafana_gen
|
||||||
|
```
|
||||||
|
|
||||||
|
### Принудительное обновление после изменения шаблона
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./grafana_gen -force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Переключение на резервную БД
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./grafana_gen -db-host 10.10.10.5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Тестирование с другим шаблоном
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./grafana_gen \
|
||||||
|
-templates-path /tmp/my-templates \
|
||||||
|
-skip-git-pull \
|
||||||
|
-dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Использование таблицы ручного ввода
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./grafana_gen -use-manual
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура БД
|
||||||
|
|
||||||
|
Утилита читает данные из двух таблиц базы `test_info`.
|
||||||
|
|
||||||
|
### Таблица `sp_info` (основная)
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|----------|
|
||||||
|
| `sid` | text | Идентификатор ресурса |
|
||||||
|
| `domain_name` | text | Доменное имя |
|
||||||
|
| `aliases` | jsonb | JSON-массив дополнительных доменов |
|
||||||
|
|
||||||
|
### Таблица `apps_settings` (справочник клиентов)
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|----------|
|
||||||
|
| `l7resourceid` | text | SID ресурса (связь с `sp_info.sid`) |
|
||||||
|
| `client_title` | text | Название клиента |
|
||||||
|
|
||||||
|
Если `client_title` не найден — клиент группируется под именем `Unknown`.
|
||||||
|
|
||||||
|
Таблица `manual_info` имеет ту же структуру, что и `sp_info`, и используется при флаге `-use-manual` для ручного управления данными без изменения основной таблицы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файловая система
|
||||||
|
|
||||||
|
```
|
||||||
|
/etc/grafana_gen/
|
||||||
|
├── grafana_gen.env # Конфигурация (секреты)
|
||||||
|
└── templates/ # Клонированный Git-репозиторий с шаблонами
|
||||||
|
└── dashboard_template.json
|
||||||
|
|
||||||
|
/var/lib/grafana_gen/
|
||||||
|
└── state.json # Состояние последнего запуска
|
||||||
|
|
||||||
|
/usr/local/bin/
|
||||||
|
└── grafana_gen # Бинарник утилиты
|
||||||
|
```
|
||||||
|
|
||||||
|
### Права доступа
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Директории
|
||||||
|
install -d -m 755 /etc/grafana_gen
|
||||||
|
install -d -m 755 /var/lib/grafana_gen
|
||||||
|
|
||||||
|
# .env файл — только для владельца процесса
|
||||||
|
chmod 600 /etc/grafana_gen/grafana_gen.env
|
||||||
|
```
|
||||||
156
config.go
Normal file
156
config.go
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Database settings
|
||||||
|
// Рекомендуется задавать через .env файл или переменные окружения
|
||||||
|
DefaultDBUser = "" // Задать через DB_USER в .env
|
||||||
|
DefaultDBPassword = "" // Задать через DB_PASSWORD в .env
|
||||||
|
DefaultDBName = "waf_info"
|
||||||
|
DefaultDBPort = 5432
|
||||||
|
DefaultPrimaryDBHost = "10.100.10.8"
|
||||||
|
DefaultSecondaryDBHost = "10.100.13.5"
|
||||||
|
|
||||||
|
// Git repository settings
|
||||||
|
DefaultTemplatesRepo = "https://svc-git.cirex.ru/cloudstack/grafana_gen_template.git"
|
||||||
|
DefaultTemplatesBranch = "master"
|
||||||
|
DefaultTemplatesPath = "/etc/grafana_gen/templates" // Системный путь
|
||||||
|
DefaultStateFile = "/var/lib/grafana_gen/state.json" // Системный путь для state
|
||||||
|
|
||||||
|
// DefaultGitToken - Gitea access token для доступа к репозиторию
|
||||||
|
// ВАЖНО: Рекомендуется задавать через .env файл или переменную окружения!
|
||||||
|
//
|
||||||
|
// Как создать токен в Gitea:
|
||||||
|
// 1. Gitea → Settings → Applications → Generate New Token
|
||||||
|
// 2. Выбрать scope: repository (read)
|
||||||
|
//
|
||||||
|
// Варианты использования (в порядке приоритета):
|
||||||
|
// 1. Флаг командной строки: -git-token "your-gitea-token"
|
||||||
|
// 2. Переменная окружения: export GIT_TOKEN="your-gitea-token"
|
||||||
|
// 3. Файл .env: GIT_TOKEN=your-gitea-token
|
||||||
|
// 4. Hardcode здесь (не рекомендуется): DefaultGitToken = "your-gitea-token"
|
||||||
|
//
|
||||||
|
// Оставьте пустым для использования только флага/env/.env файла
|
||||||
|
DefaultGitToken = ""
|
||||||
|
|
||||||
|
// DefaultGrafanaAPIKey - Grafana API ключ для создания дашбордов
|
||||||
|
// ВАЖНО: Рекомендуется задавать через .env файл или переменную окружения!
|
||||||
|
//
|
||||||
|
// Варианты использования (в порядке приоритета):
|
||||||
|
// 1. Флаг командной строки: -grafana-api-key "glsa-xxxxxxxxxxxx"
|
||||||
|
// 2. Переменная окружения: export GRAFANA_API_KEY="glsa-xxxxxxxxxxxx"
|
||||||
|
// 3. Файл .env: GRAFANA_API_KEY=glsa_xxxxxxxxxxxx
|
||||||
|
// 4. Hardcode здесь (не рекомендуется): DefaultGrafanaAPIKey = "glsa-xxxxxxxxxxxx"
|
||||||
|
//
|
||||||
|
// Оставьте пустым для использования только флага/env/.env файла
|
||||||
|
DefaultGrafanaAPIKey = ""
|
||||||
|
|
||||||
|
// DefaultGrafanaURL - URL Grafana сервера
|
||||||
|
// Можно захардкодить для быстрого запуска
|
||||||
|
DefaultGrafanaURL = ""
|
||||||
|
|
||||||
|
// Пример:
|
||||||
|
// DefaultGrafanaURL = "https://grafana.cirex.ru"
|
||||||
|
|
||||||
|
// DefaultGrafanaFolder - папка в Grafana куда будут созданы дашборды
|
||||||
|
// Пример: "WAF - PTAF" создаст путь: Home -> Dashboards -> WAF - PTAF -> [Dashboard]
|
||||||
|
// Оставьте пустым для создания в General папке (корень)
|
||||||
|
DefaultGrafanaFolder = "WAF - Auto Generated"
|
||||||
|
|
||||||
|
// Можно переопределить под ваши нужды:
|
||||||
|
// DefaultGrafanaFolder = "WAF - PTAF"
|
||||||
|
|
||||||
|
// DefaultDashboardTitle - название единого дашборда со всеми клиентами
|
||||||
|
// Пример результата: Home -> Dashboards -> WAF - PTAF -> PT AF Nodes
|
||||||
|
DefaultDashboardTitle = "PT AF Nodes"
|
||||||
|
|
||||||
|
// Другие примеры названий:
|
||||||
|
// DefaultDashboardTitle = "WAF Monitoring"
|
||||||
|
// DefaultDashboardTitle = "All Clients"
|
||||||
|
// DefaultDashboardTitle = "PTAF Overview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DBHost string
|
||||||
|
DBPort int
|
||||||
|
DBUser string
|
||||||
|
DBPassword string
|
||||||
|
DBName string
|
||||||
|
GrafanaURL string
|
||||||
|
GrafanaAPIKey string
|
||||||
|
GrafanaFolder string // Папка в Grafana для дашбордов
|
||||||
|
DashboardTitle string // Название единого дашборда
|
||||||
|
UseManualInfo bool
|
||||||
|
DryRun bool
|
||||||
|
TemplatesRepo string
|
||||||
|
TemplatesBranch string
|
||||||
|
TemplatesPath string
|
||||||
|
SkipGitPull bool
|
||||||
|
StateFile string
|
||||||
|
ForceRun bool
|
||||||
|
GitToken string // GitLab token для приватного репозитория
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientData struct {
|
||||||
|
ClientTitle string
|
||||||
|
Domains []DomainInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type DomainInfo struct {
|
||||||
|
SID string
|
||||||
|
DomainName string
|
||||||
|
Aliases string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardTemplate struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Datasource map[string]interface{} `json:"datasource"`
|
||||||
|
DashboardMeta map[string]interface{} `json:"dashboard_meta"` // uid, tags, version — больше не хардкод в Go
|
||||||
|
Dashboard map[string]interface{} `json:"dashboard"`
|
||||||
|
Layout LayoutConfig `json:"layout"`
|
||||||
|
Conditions map[string]ConditionDefinition `json:"conditions"`
|
||||||
|
PanelTemplates map[string]interface{} `json:"panel_templates"`
|
||||||
|
QueryTemplates map[string]interface{} `json:"query_templates"`
|
||||||
|
Panels map[string]interface{} `json:"panels"`
|
||||||
|
Queries map[string]interface{} `json:"queries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LayoutConfig struct {
|
||||||
|
TenantPanels []PanelLayout `json:"tenant_panels"`
|
||||||
|
DomainPanels []PanelLayout `json:"domain_panels"`
|
||||||
|
UseCollapsedRows bool `json:"use_collapsed_rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PanelLayout struct {
|
||||||
|
PanelKey string `json:"panel_key"`
|
||||||
|
PanelTemplate string `json:"panel_template,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
TitleFormat string `json:"title_format,omitempty"`
|
||||||
|
TitleSuffix string `json:"title_suffix,omitempty"`
|
||||||
|
Field string `json:"field,omitempty"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
XOffset int `json:"x_offset"`
|
||||||
|
YOffset int `json:"y_offset,omitempty"`
|
||||||
|
CollapsedRow bool `json:"collapsed_row,omitempty"`
|
||||||
|
QueryConfig *QueryConfig `json:"query_config,omitempty"`
|
||||||
|
Condition *PanelCondition `json:"condition"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryConfig struct {
|
||||||
|
BaseQuery string `json:"base_query"`
|
||||||
|
Filters []string `json:"filters"`
|
||||||
|
Metric string `json:"metric"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
Field string `json:"field,omitempty"`
|
||||||
|
Extra map[string]interface{} `json:"extra,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PanelCondition struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConditionDefinition struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
Evaluator string `json:"evaluator"`
|
||||||
|
}
|
||||||
254
dashboard.go
Normal file
254
dashboard.go
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateSingleDashboard создает один дашборд со всеми клиентами.
|
||||||
|
// Структура, метаданные и логика панелей полностью управляются шаблоном.
|
||||||
|
func generateSingleDashboard(clients map[string]ClientData, template *DashboardTemplate, config Config) map[string]interface{} {
|
||||||
|
panels := []interface{}{}
|
||||||
|
yPos := 0
|
||||||
|
panelID := 100
|
||||||
|
|
||||||
|
datasourceType := template.Datasource["type"].(string)
|
||||||
|
datasourceUID := template.Datasource["uid"].(string)
|
||||||
|
useCollapsedRows := template.Layout.UseCollapsedRows
|
||||||
|
|
||||||
|
for _, client := range clients {
|
||||||
|
if len(client.Domains) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if useCollapsedRows {
|
||||||
|
row := createCollapsedRow(client, template, datasourceType, datasourceUID, panelID, yPos)
|
||||||
|
panels = append(panels, row)
|
||||||
|
panelID += 100
|
||||||
|
yPos++
|
||||||
|
} else {
|
||||||
|
panels = append(panels, createRowPanel(client.ClientTitle, template, panelID, yPos))
|
||||||
|
panelID++
|
||||||
|
yPos++
|
||||||
|
|
||||||
|
tenantPanels, nextID, nextY := generateLayoutPanels(
|
||||||
|
template.Layout.TenantPanels, client, nil,
|
||||||
|
template, datasourceType, datasourceUID, panelID, yPos,
|
||||||
|
)
|
||||||
|
panels = append(panels, tenantPanels...)
|
||||||
|
panelID = nextID
|
||||||
|
yPos = nextY
|
||||||
|
|
||||||
|
for _, domain := range client.Domains {
|
||||||
|
domainPanels, nextID, nextY := generateLayoutPanels(
|
||||||
|
template.Layout.DomainPanels, client, &domain,
|
||||||
|
template, datasourceType, datasourceUID, panelID, yPos,
|
||||||
|
)
|
||||||
|
panels = append(panels, domainPanels...)
|
||||||
|
panelID = nextID
|
||||||
|
yPos = nextY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Берём метаданные дашборда из шаблона
|
||||||
|
dashboardBase := deepCopy(template.Dashboard).(map[string]interface{})
|
||||||
|
applyDashboardMeta(dashboardBase, template, config.DashboardTitle)
|
||||||
|
dashboardBase["panels"] = panels
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"dashboard": dashboardBase,
|
||||||
|
"overwrite": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDashboardMeta применяет метаданные из секции dashboard_meta шаблона.
|
||||||
|
// Значения из шаблона используются как дефолты; title всегда берётся из config.
|
||||||
|
func applyDashboardMeta(dashboardBase map[string]interface{}, template *DashboardTemplate, title string) {
|
||||||
|
dashboardBase["title"] = title
|
||||||
|
|
||||||
|
meta := template.DashboardMeta
|
||||||
|
if meta == nil {
|
||||||
|
// Минимальные дефолты если секция отсутствует
|
||||||
|
dashboardBase["id"] = nil
|
||||||
|
dashboardBase["version"] = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboardBase["id"] = nil // всегда nil для overwrite по uid
|
||||||
|
|
||||||
|
if uid, ok := meta["uid"].(string); ok && uid != "" {
|
||||||
|
dashboardBase["uid"] = uid
|
||||||
|
}
|
||||||
|
if tags, ok := meta["tags"]; ok {
|
||||||
|
dashboardBase["tags"] = tags
|
||||||
|
}
|
||||||
|
if version, ok := meta["version"]; ok {
|
||||||
|
dashboardBase["version"] = version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCollapsedRow создаёт row с панелями внутри (collapsed=true)
|
||||||
|
func createCollapsedRow(
|
||||||
|
client ClientData,
|
||||||
|
template *DashboardTemplate,
|
||||||
|
datasourceType, datasourceUID string,
|
||||||
|
startPanelID, yPos int,
|
||||||
|
) map[string]interface{} {
|
||||||
|
row := deepCopy(template.Panels["row"]).(map[string]interface{})
|
||||||
|
row["id"] = startPanelID
|
||||||
|
row["title"] = client.ClientTitle
|
||||||
|
row["collapsed"] = true
|
||||||
|
|
||||||
|
gridPos := row["gridPos"].(map[string]interface{})
|
||||||
|
gridPos["y"] = yPos
|
||||||
|
|
||||||
|
insidePanels := []interface{}{}
|
||||||
|
panelID := startPanelID + 1
|
||||||
|
|
||||||
|
for _, layout := range template.Layout.TenantPanels {
|
||||||
|
if !evaluateCondition(layout.Condition, client, nil) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
domain := tenantDomain(client)
|
||||||
|
panel := buildPanel(
|
||||||
|
layout.PanelKey, template, domain, client,
|
||||||
|
datasourceType, datasourceUID,
|
||||||
|
panelID, yOffsetOrDefault(layout.YOffset, 1), layout.XOffset, layout.Width,
|
||||||
|
)
|
||||||
|
if panel == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applyTitle(panel, layout, client.ClientTitle, "", "")
|
||||||
|
insidePanels = append(insidePanels, panel)
|
||||||
|
panelID++
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, domain := range client.Domains {
|
||||||
|
for _, layout := range template.Layout.DomainPanels {
|
||||||
|
if !evaluateCondition(layout.Condition, client, &domain) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
panel := buildPanel(
|
||||||
|
layout.PanelKey, template, domain, client,
|
||||||
|
datasourceType, datasourceUID,
|
||||||
|
panelID, layout.YOffset, layout.XOffset, layout.Width,
|
||||||
|
)
|
||||||
|
if panel == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applyTitle(panel, layout, client.ClientTitle, domain.DomainName, domain.SID)
|
||||||
|
panel["description"] = domain.Aliases
|
||||||
|
insidePanels = append(insidePanels, panel)
|
||||||
|
panelID++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row["panels"] = insidePanels
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateLayoutPanels генерирует список панелей по layouts (режим без collapsed rows)
|
||||||
|
func generateLayoutPanels(
|
||||||
|
layouts []PanelLayout,
|
||||||
|
client ClientData,
|
||||||
|
domain *DomainInfo,
|
||||||
|
template *DashboardTemplate,
|
||||||
|
datasourceType, datasourceUID string,
|
||||||
|
startPanelID, startY int,
|
||||||
|
) ([]interface{}, int, int) {
|
||||||
|
panels := []interface{}{}
|
||||||
|
panelID := startPanelID
|
||||||
|
maxHeight := 0
|
||||||
|
|
||||||
|
effectiveDomain := tenantDomain(client)
|
||||||
|
if domain != nil {
|
||||||
|
effectiveDomain = *domain
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, layout := range layouts {
|
||||||
|
if !evaluateCondition(layout.Condition, client, domain) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
panel := buildPanel(
|
||||||
|
layout.PanelKey, template, effectiveDomain, client,
|
||||||
|
datasourceType, datasourceUID,
|
||||||
|
panelID, startY, layout.XOffset, layout.Width,
|
||||||
|
)
|
||||||
|
if panel == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applyTitle(panel, layout, client.ClientTitle, effectiveDomain.DomainName, effectiveDomain.SID)
|
||||||
|
if domain != nil {
|
||||||
|
panel["description"] = domain.Aliases
|
||||||
|
}
|
||||||
|
panels = append(panels, panel)
|
||||||
|
panelID++
|
||||||
|
|
||||||
|
gridPos := panel["gridPos"].(map[string]interface{})
|
||||||
|
if h, ok := gridPos["h"].(float64); ok && int(h) > maxHeight {
|
||||||
|
maxHeight = int(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return panels, panelID, startY + maxHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTitle применяет title_format или title_suffix к панели.
|
||||||
|
// Поддерживаемые плейсхолдеры: {client}, {domain}, {sid}, %s (fmt.Sprintf для clientTitle).
|
||||||
|
// title_suffix: результат "<domainName> <suffix>" с подстановкой плейсхолдеров.
|
||||||
|
// title_format: полный контроль над заголовком через плейсхолдеры.
|
||||||
|
func applyTitle(panel map[string]interface{}, layout PanelLayout, clientTitle, domainName, sid string) {
|
||||||
|
applyPlaceholders := func(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "{client}", clientTitle)
|
||||||
|
s = strings.ReplaceAll(s, "{domain}", domainName)
|
||||||
|
s = strings.ReplaceAll(s, "{sid}", sid)
|
||||||
|
if strings.Contains(s, "%s") {
|
||||||
|
s = fmt.Sprintf(s, clientTitle)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
if layout.TitleFormat != "" {
|
||||||
|
panel["title"] = applyPlaceholders(layout.TitleFormat)
|
||||||
|
} else if layout.TitleSuffix != "" && domainName != "" {
|
||||||
|
panel["title"] = fmt.Sprintf("%s %s", domainName, applyPlaceholders(layout.TitleSuffix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenantDomain создаёт псевдо-домен для tenant-level панелей (все SID клиента через OR)
|
||||||
|
func tenantDomain(client ClientData) DomainInfo {
|
||||||
|
return DomainInfo{
|
||||||
|
SID: buildTenantSIDQuery(client.Domains),
|
||||||
|
DomainName: client.ClientTitle,
|
||||||
|
Aliases: fmt.Sprintf("%d domains", len(client.Domains)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTenantSIDQuery(domains []DomainInfo) string {
|
||||||
|
sids := make([]string, 0, len(domains))
|
||||||
|
for _, d := range domains {
|
||||||
|
sids = append(sids, d.SID)
|
||||||
|
}
|
||||||
|
return strings.Join(sids, " OR ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createRowPanel(title string, template *DashboardTemplate, id, yPos int) map[string]interface{} {
|
||||||
|
row := deepCopy(template.Panels["row"]).(map[string]interface{})
|
||||||
|
row["id"] = id
|
||||||
|
row["title"] = title
|
||||||
|
row["collapsed"] = false
|
||||||
|
row["gridPos"].(map[string]interface{})["y"] = yPos
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func yOffsetOrDefault(yOffset, defaultVal int) int {
|
||||||
|
if yOffset > 0 {
|
||||||
|
return yOffset
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заглушка чтобы компилятор не ругался на неиспользуемый импорт
|
||||||
|
var _ = log.Printf
|
||||||
104
database.go
Normal file
104
database.go
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func connectDB(config Config) (*sql.DB, error) {
|
||||||
|
connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
config.DBHost, config.DBPort, config.DBUser, config.DBPassword, config.DBName)
|
||||||
|
|
||||||
|
db, err := sql.Open("postgres", connStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchClientsData(db *sql.DB, useManual bool) (map[string]ClientData, error) {
|
||||||
|
tableName := "sp_info"
|
||||||
|
if useManual {
|
||||||
|
tableName = "manual_info"
|
||||||
|
}
|
||||||
|
|
||||||
|
// aliases это JSON массив в БД, конвертируем в текст через ::text
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT DISTINCT
|
||||||
|
COALESCE(a.client_title, 'Unknown') as client_title,
|
||||||
|
s.sid,
|
||||||
|
s.domain_name,
|
||||||
|
COALESCE(s.aliases::text, 'null') as aliases
|
||||||
|
FROM %s s
|
||||||
|
LEFT JOIN apps_settings a ON s.sid = a.l7resourceid
|
||||||
|
WHERE s.sid IS NOT NULL
|
||||||
|
ORDER BY COALESCE(a.client_title, 'Unknown'), s.sid
|
||||||
|
`, tableName)
|
||||||
|
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query failed: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
clients := make(map[string]ClientData)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var clientTitle, sid, domainName, aliasesJSON string
|
||||||
|
|
||||||
|
if err := rows.Scan(&clientTitle, &sid, &domainName, &aliasesJSON); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Распарсить JSON массив aliases
|
||||||
|
aliasesStr := parseAliasesJSON(aliasesJSON)
|
||||||
|
|
||||||
|
if _, exists := clients[clientTitle]; !exists {
|
||||||
|
clients[clientTitle] = ClientData{
|
||||||
|
ClientTitle: clientTitle,
|
||||||
|
Domains: []DomainInfo{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := clients[clientTitle]
|
||||||
|
client.Domains = append(client.Domains, DomainInfo{
|
||||||
|
SID: sid,
|
||||||
|
DomainName: domainName,
|
||||||
|
Aliases: aliasesStr,
|
||||||
|
})
|
||||||
|
clients[clientTitle] = client
|
||||||
|
}
|
||||||
|
|
||||||
|
return clients, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAliasesJSON конвертирует JSON массив aliases в строку
|
||||||
|
// Input: ["domain1.com", "domain2.com"] или null
|
||||||
|
// Output: "domain1.com, domain2.com" или ""
|
||||||
|
func parseAliasesJSON(jsonStr string) string {
|
||||||
|
// Если null или пустое
|
||||||
|
if jsonStr == "" || jsonStr == "null" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Попытаться распарсить как JSON массив
|
||||||
|
var aliases []string
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &aliases); err != nil {
|
||||||
|
// Если не удалось распарсить - вернуть как есть
|
||||||
|
log.Printf("Warning: Failed to parse aliases JSON: %s (error: %v)", jsonStr, err)
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Соединить через запятую
|
||||||
|
return strings.Join(aliases, ", ")
|
||||||
|
}
|
||||||
112
env.go
Normal file
112
env.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultEnvFile = "/etc/grafana_gen/grafana_gen.env"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadEnvFile загружает переменные из .env файла
|
||||||
|
// Не переопределяет уже установленные переменные окружения
|
||||||
|
func loadEnvFile(filepath string) error {
|
||||||
|
// Если файл не существует - это не ошибка
|
||||||
|
if _, err := os.Stat(filepath); os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open env file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
lineNum := 0
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineNum++
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
|
// Пропустить пустые строки и комментарии
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разделить на KEY=VALUE
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return fmt.Errorf("invalid format at line %d: %s", lineNum, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
value := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
// Убрать кавычки если есть
|
||||||
|
value = strings.Trim(value, "\"'")
|
||||||
|
|
||||||
|
// Установить переменную окружения ТОЛЬКО если её ещё нет
|
||||||
|
if os.Getenv(key) == "" {
|
||||||
|
if err := os.Setenv(key, value); err != nil {
|
||||||
|
return fmt.Errorf("failed to set %s: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return fmt.Errorf("error reading env file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEnvFileWithFallback загружает .env файл с fallback путями
|
||||||
|
func loadEnvFileWithFallback() error {
|
||||||
|
// Попробовать пути в порядке приоритета:
|
||||||
|
// 1. Переменная окружения GRAFANA_GEN_ENV_FILE
|
||||||
|
// 2. /etc/grafana_gen/grafana_gen.env
|
||||||
|
// 3. ./grafana_gen.env (текущая директория)
|
||||||
|
// 4. ./.env
|
||||||
|
|
||||||
|
paths := []string{
|
||||||
|
os.Getenv("GRAFANA_GEN_ENV_FILE"),
|
||||||
|
DefaultEnvFile,
|
||||||
|
"./grafana_gen.env",
|
||||||
|
"./.env",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
if path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := loadEnvFile(path); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если загрузка успешна
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если ни один файл не найден - это не ошибка (все переменные через flags/env)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEnvFileQuiet загружает .env файл без логирования
|
||||||
|
// Возвращает true если файл был загружен
|
||||||
|
func loadEnvFileQuiet(filepath string) bool {
|
||||||
|
if err := loadEnvFile(filepath); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Проверить что файл существует
|
||||||
|
if _, err := os.Stat(filepath); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
207
git.go
Normal file
207
git.go
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func updateTemplates(config Config) (string, error) {
|
||||||
|
log.Printf("Updating templates from Git: %s (branch: %s)", sanitizeRepoURL(config.TemplatesRepo), config.TemplatesBranch)
|
||||||
|
|
||||||
|
// Check if templates directory exists
|
||||||
|
if _, err := os.Stat(config.TemplatesPath); os.IsNotExist(err) {
|
||||||
|
// Clone repository
|
||||||
|
if err := cloneRepository(config); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return getCurrentCommit(config.TemplatesPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a valid git repository
|
||||||
|
if !isGitRepository(config.TemplatesPath) {
|
||||||
|
log.Printf("Warning: %s exists but is not a git repository", config.TemplatesPath)
|
||||||
|
log.Printf("Remove the directory or use -skip-git-pull flag")
|
||||||
|
return "", fmt.Errorf("not a git repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull latest changes and detect if there were updates
|
||||||
|
if err := pullRepository(config); err != nil {
|
||||||
|
return getCurrentCommit(config.TemplatesPath), err
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCurrentCommit(config.TemplatesPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneRepository(config Config) error {
|
||||||
|
log.Printf("Cloning templates repository...")
|
||||||
|
|
||||||
|
repoURL := buildRepoURL(config.TemplatesRepo, config.GitToken)
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "clone", "-b", config.TemplatesBranch, repoURL, config.TemplatesPath)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
// Don't show stderr to avoid leaking token
|
||||||
|
var stderr strings.Builder
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// Sanitize error message
|
||||||
|
errMsg := sanitizeGitError(stderr.String())
|
||||||
|
return fmt.Errorf("failed to clone repository: %w\n%s", err, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Templates cloned successfully")
|
||||||
|
showCurrentCommit(config.TemplatesPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pullRepository(config Config) error {
|
||||||
|
// Get current commit before pull
|
||||||
|
oldCommit := getCurrentCommit(config.TemplatesPath)
|
||||||
|
|
||||||
|
log.Printf("Pulling latest templates...")
|
||||||
|
|
||||||
|
// Update remote URL with token if provided
|
||||||
|
if config.GitToken != "" {
|
||||||
|
if err := updateRemoteURL(config); err != nil {
|
||||||
|
log.Printf("Warning: Failed to update remote URL: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "-C", config.TemplatesPath, "pull", "origin", config.TemplatesBranch)
|
||||||
|
var stdout, stderr strings.Builder
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
errMsg := sanitizeGitError(stderr.String())
|
||||||
|
return fmt.Errorf("failed to pull repository: %w\n%s", err, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current commit after pull
|
||||||
|
newCommit := getCurrentCommit(config.TemplatesPath)
|
||||||
|
|
||||||
|
// Check if there were changes
|
||||||
|
if oldCommit == newCommit {
|
||||||
|
log.Printf("No new changes in templates (commit: %s)", truncateHash(oldCommit))
|
||||||
|
} else if oldCommit == "" {
|
||||||
|
log.Printf("Successfully pulled templates")
|
||||||
|
showCurrentCommit(config.TemplatesPath)
|
||||||
|
} else {
|
||||||
|
log.Printf("Templates updated: %s -> %s", truncateHash(oldCommit), truncateHash(newCommit))
|
||||||
|
showCommitDiff(config.TemplatesPath, oldCommit, newCommit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateRemoteURL(config Config) error {
|
||||||
|
repoURL := buildRepoURL(config.TemplatesRepo, config.GitToken)
|
||||||
|
cmd := exec.Command("git", "-C", config.TemplatesPath, "remote", "set-url", "origin", repoURL)
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRepoURL(repoURL, token string) string {
|
||||||
|
if token == "" {
|
||||||
|
return repoURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Gitea, format: https://TOKEN@svc-git.cirex.ru/...
|
||||||
|
// Remove https:// prefix if present
|
||||||
|
cleanURL := strings.TrimPrefix(repoURL, "https://")
|
||||||
|
cleanURL = strings.TrimPrefix(cleanURL, "http://")
|
||||||
|
|
||||||
|
return fmt.Sprintf("https://%s@%s", token, cleanURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeRepoURL(repoURL string) string {
|
||||||
|
// Remove token from URL for logging
|
||||||
|
if strings.Contains(repoURL, "@") {
|
||||||
|
parts := strings.SplitN(repoURL, "@", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
// Keep protocol and domain
|
||||||
|
protocol := "https://"
|
||||||
|
if strings.HasPrefix(repoURL, "http://") {
|
||||||
|
protocol = "http://"
|
||||||
|
}
|
||||||
|
return protocol + "***:***@" + parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repoURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeGitError(errMsg string) string {
|
||||||
|
// Remove potential token from error messages
|
||||||
|
// Support both Gitea (TOKEN@) and GitLab (oauth2:TOKEN@) formats
|
||||||
|
|
||||||
|
// Gitea format: https://TOKEN@host
|
||||||
|
if strings.Contains(errMsg, "@") && strings.Contains(errMsg, "://") {
|
||||||
|
parts := strings.Split(errMsg, "://")
|
||||||
|
for i := 1; i < len(parts); i++ {
|
||||||
|
atIndex := strings.Index(parts[i], "@")
|
||||||
|
if atIndex > 0 {
|
||||||
|
parts[i] = "***@" + parts[i][atIndex+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errMsg = strings.Join(parts, "://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitLab format: https://oauth2:TOKEN@host (legacy support)
|
||||||
|
if strings.Contains(errMsg, "oauth2:") {
|
||||||
|
parts := strings.Split(errMsg, "oauth2:")
|
||||||
|
for i := 1; i < len(parts); i++ {
|
||||||
|
atIndex := strings.Index(parts[i], "@")
|
||||||
|
if atIndex > 0 {
|
||||||
|
parts[i] = "***" + parts[i][atIndex:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errMsg = strings.Join(parts, "oauth2:")
|
||||||
|
}
|
||||||
|
|
||||||
|
return errMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGitRepository(path string) bool {
|
||||||
|
gitDir := path + "/.git"
|
||||||
|
_, err := os.Stat(gitDir)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCurrentCommit(templatesPath string) string {
|
||||||
|
cmd := exec.Command("git", "-C", templatesPath, "rev-parse", "HEAD")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
func showCurrentCommit(templatesPath string) {
|
||||||
|
cmd := exec.Command("git", "-C", templatesPath, "log", "-1", "--oneline")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
log.Printf("Current template commit: %s", strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showCommitDiff(templatesPath, oldCommit, newCommit string) {
|
||||||
|
// Show files changed
|
||||||
|
cmd := exec.Command("git", "-C", templatesPath, "diff", "--name-only", oldCommit, newCommit)
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err == nil && len(output) > 0 {
|
||||||
|
files := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
log.Printf("Changed files:")
|
||||||
|
for _, file := range files {
|
||||||
|
log.Printf(" - %s", file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateHash(hash string) string {
|
||||||
|
if len(hash) > 8 {
|
||||||
|
return hash[:8]
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
}
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
module grafana-dashboard-generator
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require github.com/lib/pq v1.10.9
|
||||||
182
grafana.go
Normal file
182
grafana.go
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sendToGrafana отправляет дашборд в Grafana
|
||||||
|
// Дашборд будет создан в указанной папке (folder)
|
||||||
|
func sendToGrafana(dashboard map[string]interface{}, config Config) error {
|
||||||
|
// Получить или создать folder ID
|
||||||
|
folderID, err := ensureFolder(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to ensure folder exists: %v", err)
|
||||||
|
log.Printf("Dashboard will be created in General folder")
|
||||||
|
folderID = 0 // General folder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавить folder ID в запрос
|
||||||
|
dashboard["folderId"] = folderID
|
||||||
|
if folderID > 0 {
|
||||||
|
log.Printf("Creating dashboard in folder: %s (ID: %d)", config.GrafanaFolder, folderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(dashboard)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal dashboard: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/api/dashboards/db", strings.TrimRight(config.GrafanaURL, "/"))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.GrafanaAPIKey))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("grafana API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response to get dashboard URL
|
||||||
|
var response map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &response); err == nil {
|
||||||
|
if url, ok := response["url"].(string); ok {
|
||||||
|
log.Printf("Dashboard URL: %s%s", config.GrafanaURL, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureFolder создает папку если её нет и возвращает её ID
|
||||||
|
func ensureFolder(config Config) (int, error) {
|
||||||
|
if config.GrafanaFolder == "" {
|
||||||
|
return 0, nil // General folder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск существующей папки
|
||||||
|
folderID, err := findFolder(config)
|
||||||
|
if err == nil && folderID > 0 {
|
||||||
|
return folderID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создать папку если не найдена
|
||||||
|
return createFolder(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findFolder ищет папку по имени
|
||||||
|
func findFolder(config Config) (int, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/folders", strings.TrimRight(config.GrafanaURL, "/"))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.GrafanaAPIKey))
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return 0, fmt.Errorf("failed to list folders: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
var folders []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &folders); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по title
|
||||||
|
for _, folder := range folders {
|
||||||
|
if title, ok := folder["title"].(string); ok && title == config.GrafanaFolder {
|
||||||
|
if id, ok := folder["id"].(float64); ok {
|
||||||
|
log.Printf("Found existing folder: %s (ID: %d)", config.GrafanaFolder, int(id))
|
||||||
|
return int(id), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, fmt.Errorf("folder not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// createFolder создает новую папку
|
||||||
|
func createFolder(config Config) (int, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/folders", strings.TrimRight(config.GrafanaURL, "/"))
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"title": config.GrafanaFolder,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.GrafanaAPIKey))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return 0, fmt.Errorf("failed to create folder: status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &response); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if id, ok := response["id"].(float64); ok {
|
||||||
|
log.Printf("Created new folder: %s (ID: %d)", config.GrafanaFolder, int(id))
|
||||||
|
return int(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, fmt.Errorf("failed to get folder ID from response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func logDryRun(clientTitle string, dashboard map[string]interface{}) {
|
||||||
|
jsonData, _ := json.MarshalIndent(dashboard, "", " ")
|
||||||
|
log.Printf("DRY RUN: Dashboard for %s (%d bytes)", clientTitle, len(jsonData))
|
||||||
|
// Optionally print first 500 chars
|
||||||
|
if len(jsonData) > 500 {
|
||||||
|
log.Printf("Preview: %s...", string(jsonData[:500]))
|
||||||
|
} else {
|
||||||
|
log.Printf("Preview: %s", string(jsonData))
|
||||||
|
}
|
||||||
|
}
|
||||||
283
main.go
Normal file
283
main.go
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load environment variables from .env file before parsing flags
|
||||||
|
// This allows .env file to provide defaults that can be overridden by flags
|
||||||
|
loadEnvFileWithFallback()
|
||||||
|
|
||||||
|
config := parseFlags()
|
||||||
|
|
||||||
|
// Initialize state manager
|
||||||
|
stateManager := NewStateManager(config.StateFile)
|
||||||
|
if err := stateManager.Load(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to load state: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show last run info
|
||||||
|
if lastRun := stateManager.GetLastRun(); !lastRun.IsZero() {
|
||||||
|
log.Printf("Last successful run: %s", lastRun.Format("2006-01-02 15:04:05"))
|
||||||
|
clientCount, domainCount, sidCount := stateManager.GetStats()
|
||||||
|
log.Printf("Previous state: %d clients, %d domains, %d SIDs", clientCount, domainCount, sidCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update templates from Git
|
||||||
|
var templateCommit string
|
||||||
|
if !config.SkipGitPull {
|
||||||
|
commit, err := updateTemplates(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to update templates from Git: %v", err)
|
||||||
|
log.Printf("Continuing with existing templates...")
|
||||||
|
}
|
||||||
|
templateCommit = commit
|
||||||
|
} else {
|
||||||
|
// Get current commit without pulling
|
||||||
|
templateCommit = getCurrentCommit(config.TemplatesPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load dashboard template
|
||||||
|
template, err := loadTemplate(config.TemplatesPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Loaded template version: %s", template.Version)
|
||||||
|
|
||||||
|
// Connect to database
|
||||||
|
db, err := connectDB(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Fetch clients data
|
||||||
|
clients, err := fetchClientsData(db, config.UseManualInfo)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to fetch clients data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Found %d clients", len(clients))
|
||||||
|
|
||||||
|
// Check if there are any clients
|
||||||
|
if len(clients) == 0 {
|
||||||
|
log.Printf("No clients found in database. Nothing to generate.")
|
||||||
|
log.Printf("This is not an error - database is empty or no data matches criteria.")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out clients without domains
|
||||||
|
validClients := make(map[string]ClientData)
|
||||||
|
skippedCount := 0
|
||||||
|
for clientTitle, clientData := range clients {
|
||||||
|
if len(clientData.Domains) == 0 {
|
||||||
|
log.Printf("Warning: Client %s has no domains, skipping", clientTitle)
|
||||||
|
skippedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validClients[clientTitle] = clientData
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(validClients) == 0 {
|
||||||
|
log.Printf("No valid clients with domains found. Nothing to generate.")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for changes
|
||||||
|
hasChanges, changeReason := stateManager.CheckChanges(templateCommit, template.Version, clients)
|
||||||
|
|
||||||
|
if !hasChanges && !config.ForceRun {
|
||||||
|
log.Printf("=== No Changes Detected ===")
|
||||||
|
log.Printf("No changes in templates or database since last run.")
|
||||||
|
log.Printf("Skipping dashboard generation.")
|
||||||
|
log.Printf("")
|
||||||
|
log.Printf("Use -force flag to regenerate anyway.")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasChanges {
|
||||||
|
log.Printf("=== Changes Detected ===")
|
||||||
|
log.Printf("%s", changeReason)
|
||||||
|
log.Printf("")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.ForceRun {
|
||||||
|
log.Printf("Force run enabled - regenerating dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate single dashboard with all clients
|
||||||
|
log.Printf("Generating single dashboard: %s", config.DashboardTitle)
|
||||||
|
log.Printf("Including %d clients with %d total domains", len(validClients), countTotalDomains(validClients))
|
||||||
|
|
||||||
|
dashboard := generateSingleDashboard(validClients, template, config)
|
||||||
|
|
||||||
|
if config.DryRun {
|
||||||
|
logDryRun(config.DashboardTitle, dashboard)
|
||||||
|
log.Printf("=== Summary ===")
|
||||||
|
log.Printf("Clients: %d", len(validClients))
|
||||||
|
log.Printf("Skipped: %d", skippedCount)
|
||||||
|
log.Printf("DRY RUN: Dashboard generated but not sent to Grafana")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to Grafana
|
||||||
|
if err := sendToGrafana(dashboard, config); err != nil {
|
||||||
|
log.Printf("Error sending dashboard: %v", err)
|
||||||
|
log.Printf("=== Summary ===")
|
||||||
|
log.Printf("Clients: %d", len(validClients))
|
||||||
|
log.Printf("Skipped: %d", skippedCount)
|
||||||
|
log.Printf("Status: FAILED")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Successfully created/updated dashboard: %s", config.DashboardTitle)
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
log.Printf("=== Summary ===")
|
||||||
|
log.Printf("Dashboard: %s", config.DashboardTitle)
|
||||||
|
log.Printf("Clients: %d", len(validClients))
|
||||||
|
if skippedCount > 0 {
|
||||||
|
log.Printf("Skipped: %d", skippedCount)
|
||||||
|
}
|
||||||
|
log.Printf("Total domains: %d", countTotalDomains(validClients))
|
||||||
|
log.Printf("Status: SUCCESS")
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
stateManager.Update(templateCommit, template.Version, clients, changeReason)
|
||||||
|
if err := stateManager.Save(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to save state: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("State saved to %s", config.StateFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countTotalDomains(clients map[string]ClientData) int {
|
||||||
|
count := 0
|
||||||
|
for _, client := range clients {
|
||||||
|
count += len(client.Domains)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlags() Config {
|
||||||
|
config := Config{}
|
||||||
|
|
||||||
|
// Database flags - resolve from env vars with fallback to defaults
|
||||||
|
dbUser := getEnvOrDefault("DB_USER", DefaultDBUser)
|
||||||
|
dbPassword := getEnvOrDefault("DB_PASSWORD", DefaultDBPassword)
|
||||||
|
|
||||||
|
// Grafana settings - resolve from env vars with fallback to defaults
|
||||||
|
grafanaFolder := getEnvOrDefault("GRAFANA_FOLDER", DefaultGrafanaFolder)
|
||||||
|
dashboardTitle := getEnvOrDefault("DASHBOARD_TITLE", DefaultDashboardTitle)
|
||||||
|
|
||||||
|
flag.StringVar(&config.DBHost, "db-host", DefaultPrimaryDBHost, "PostgreSQL host")
|
||||||
|
flag.IntVar(&config.DBPort, "db-port", DefaultDBPort, "PostgreSQL port")
|
||||||
|
flag.StringVar(&config.DBUser, "db-user", dbUser, "PostgreSQL user")
|
||||||
|
flag.StringVar(&config.DBPassword, "db-password", dbPassword, "PostgreSQL password")
|
||||||
|
flag.StringVar(&config.DBName, "db-name", DefaultDBName, "PostgreSQL database name")
|
||||||
|
flag.BoolVar(&config.UseManualInfo, "use-manual", false, "Use manual_info table instead of sp_info")
|
||||||
|
|
||||||
|
// Grafana flags
|
||||||
|
var grafanaURLFlag, grafanaAPIKeyFlag string
|
||||||
|
flag.StringVar(&grafanaURLFlag, "grafana-url", "", "Grafana URL")
|
||||||
|
flag.StringVar(&grafanaAPIKeyFlag, "grafana-api-key", "", "Grafana API key")
|
||||||
|
flag.StringVar(&config.GrafanaFolder, "grafana-folder", grafanaFolder, "Grafana folder for dashboards")
|
||||||
|
flag.StringVar(&config.DashboardTitle, "dashboard-title", dashboardTitle, "Dashboard title")
|
||||||
|
|
||||||
|
// Git flags
|
||||||
|
var gitTokenFlag string
|
||||||
|
flag.StringVar(&gitTokenFlag, "git-token", "", "Gitea token (overrides env var and hardcoded value)")
|
||||||
|
flag.StringVar(&config.TemplatesRepo, "templates-repo", DefaultTemplatesRepo, "Git repository URL for templates")
|
||||||
|
flag.StringVar(&config.TemplatesBranch, "templates-branch", DefaultTemplatesBranch, "Git branch for templates")
|
||||||
|
flag.StringVar(&config.TemplatesPath, "templates-path", DefaultTemplatesPath, "Local path for templates")
|
||||||
|
flag.BoolVar(&config.SkipGitPull, "skip-git-pull", false, "Skip updating templates from Git")
|
||||||
|
|
||||||
|
// Other flags
|
||||||
|
flag.BoolVar(&config.DryRun, "dry-run", false, "Generate JSON but don't send to Grafana")
|
||||||
|
flag.StringVar(&config.StateFile, "state-file", DefaultStateFile, "Path to state file")
|
||||||
|
flag.BoolVar(&config.ForceRun, "force", false, "Force regeneration even if no changes detected")
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Resolve tokens with priority
|
||||||
|
config.GitToken = resolveToken("Git", gitTokenFlag, "GIT_TOKEN", DefaultGitToken)
|
||||||
|
config.GrafanaAPIKey = resolveToken("Grafana API", grafanaAPIKeyFlag, "GRAFANA_API_KEY", DefaultGrafanaAPIKey)
|
||||||
|
config.GrafanaURL = resolveValue("Grafana URL", grafanaURLFlag, "GRAFANA_URL", DefaultGrafanaURL)
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if config.GrafanaURL == "" {
|
||||||
|
log.Fatal("Error: grafana-url is required (use -grafana-url flag, GRAFANA_URL env var, .env file, or set DefaultGrafanaURL in config.go)")
|
||||||
|
}
|
||||||
|
if config.GrafanaAPIKey == "" {
|
||||||
|
log.Fatal("Error: grafana-api-key is required (use -grafana-api-key flag, GRAFANA_API_KEY env var, .env file, or set DefaultGrafanaAPIKey in config.go)")
|
||||||
|
}
|
||||||
|
if config.DBUser == "" {
|
||||||
|
log.Fatal("Error: db-user is required (use -db-user flag, DB_USER env var, or .env file)")
|
||||||
|
}
|
||||||
|
if config.DBPassword == "" {
|
||||||
|
log.Fatal("Error: db-password is required (use -db-password flag, DB_PASSWORD env var, or .env file)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveToken определяет токен с приоритетом:
|
||||||
|
// 1. Флаг командной строки
|
||||||
|
// 2. Переменная окружения
|
||||||
|
// 3. Hardcoded константа
|
||||||
|
func resolveToken(name, flagValue, envVar, defaultValue string) string {
|
||||||
|
// Приоритет 1: флаг командной строки
|
||||||
|
if flagValue != "" {
|
||||||
|
log.Printf("Using %s token from command line flag", name)
|
||||||
|
return flagValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приоритет 2: переменная окружения
|
||||||
|
if envValue := os.Getenv(envVar); envValue != "" {
|
||||||
|
log.Printf("Using %s token from %s environment variable", name, envVar)
|
||||||
|
return envValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приоритет 3: hardcoded в config.go
|
||||||
|
if defaultValue != "" {
|
||||||
|
log.Printf("Using %s token from config.go (hardcoded)", name)
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Токен не задан
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveValue определяет значение с приоритетом (для non-secret параметров)
|
||||||
|
func resolveValue(name, flagValue, envVar, defaultValue string) string {
|
||||||
|
// Приоритет 1: флаг командной строки
|
||||||
|
if flagValue != "" {
|
||||||
|
return flagValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приоритет 2: переменная окружения
|
||||||
|
if envValue := os.Getenv(envVar); envValue != "" {
|
||||||
|
log.Printf("Using %s from %s environment variable", name, envVar)
|
||||||
|
return envValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приоритет 3: hardcoded в config.go
|
||||||
|
if defaultValue != "" {
|
||||||
|
log.Printf("Using %s from config.go (hardcoded)", name)
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnvOrDefault возвращает переменную окружения или default значение
|
||||||
|
func getEnvOrDefault(envVar, defaultValue string) string {
|
||||||
|
if value := os.Getenv(envVar); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
313
panels.go
Normal file
313
panels.go
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// targetBuilder — тип функции-строителя targets для конкретного query_mode.
|
||||||
|
type targetBuilder func(cfg map[string]interface{}, sidQuery string, datasourceType, datasourceUID string, template *DashboardTemplate, domain DomainInfo, client ClientData) []interface{}
|
||||||
|
|
||||||
|
// queryModeRegistry — реестр строителей targets по query_mode.
|
||||||
|
// Чтобы добавить новый тип панели в Go: одна строка здесь + функция ниже.
|
||||||
|
// Всё остальное — только в JSON.
|
||||||
|
var queryModeRegistry = map[string]targetBuilder{
|
||||||
|
"rps": buildRPSTargets,
|
||||||
|
"multi_target": buildMultiTargets,
|
||||||
|
"range_percent": buildRangePercentTargets,
|
||||||
|
"multi_sum_expression": buildMultiSumExpressionTargets,
|
||||||
|
"sum_expression": buildSumExpressionTargets,
|
||||||
|
"bucket_logs": buildBucketLogsTargets,
|
||||||
|
}
|
||||||
|
|
||||||
|
// conditionEvaluator — тип функции-вычислителя условия отображения панели.
|
||||||
|
type conditionEvaluator func(value interface{}, client ClientData, domain *DomainInfo) bool
|
||||||
|
|
||||||
|
// conditionRegistry — реестр вычислителей условий по типу.
|
||||||
|
// Чтобы добавить новый тип условия: одна строка здесь + лямбда.
|
||||||
|
var conditionRegistry = map[string]conditionEvaluator{
|
||||||
|
"min_domains": func(value interface{}, client ClientData, _ *DomainInfo) bool {
|
||||||
|
return len(client.Domains) >= int(value.(float64))
|
||||||
|
},
|
||||||
|
"max_domains": func(value interface{}, client ClientData, _ *DomainInfo) bool {
|
||||||
|
return len(client.Domains) <= int(value.(float64))
|
||||||
|
},
|
||||||
|
"client_name_matches": func(value interface{}, client ClientData, _ *DomainInfo) bool {
|
||||||
|
return strings.Contains(client.ClientTitle, value.(string))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPanel — универсальная функция построения панели.
|
||||||
|
// Тип панели и логика запросов полностью определяются полем query_mode в JSON-шаблоне.
|
||||||
|
func buildPanel(
|
||||||
|
panelKey string,
|
||||||
|
template *DashboardTemplate,
|
||||||
|
domain DomainInfo,
|
||||||
|
client ClientData,
|
||||||
|
datasourceType, datasourceUID string,
|
||||||
|
id, yPos, xPos, width int,
|
||||||
|
) map[string]interface{} {
|
||||||
|
panelDef, ok := template.Panels[panelKey]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("Warning: panel '%s' not found in template", panelKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
panelMap, ok := panelDef.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
queryMode, _ := panelMap["query_mode"].(string)
|
||||||
|
if queryMode == "" {
|
||||||
|
log.Printf("Warning: panel '%s' has no query_mode", panelKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
builder, ok := queryModeRegistry[queryMode]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("Warning: unknown query_mode '%s' for panel '%s'. Available: %v", queryMode, panelKey, registeredModes())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
queryConfigRaw, _ := panelMap["query_config"].(map[string]interface{})
|
||||||
|
panel := copyPanelBase(panelMap, id, yPos, xPos, width)
|
||||||
|
sidQuery := buildSIDQuery(domain.SID)
|
||||||
|
|
||||||
|
panel["targets"] = builder(queryConfigRaw, sidQuery, datasourceType, datasourceUID, template, domain, client)
|
||||||
|
panel["datasource"] = map[string]interface{}{
|
||||||
|
"type": datasourceType,
|
||||||
|
"uid": datasourceUID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return panel
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateCondition проверяет условие отображения панели через реестр
|
||||||
|
func evaluateCondition(condition *PanelCondition, client ClientData, domain *DomainInfo) bool {
|
||||||
|
if condition == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
evaluator, ok := conditionRegistry[condition.Type]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("Warning: unknown condition type '%s'. Available: %v", condition.Type, registeredConditions())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return evaluator(condition.Value, client, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registeredModes() []string {
|
||||||
|
modes := make([]string, 0, len(queryModeRegistry))
|
||||||
|
for k := range queryModeRegistry {
|
||||||
|
modes = append(modes, k)
|
||||||
|
}
|
||||||
|
return modes
|
||||||
|
}
|
||||||
|
|
||||||
|
func registeredConditions() []string {
|
||||||
|
types := make([]string, 0, len(conditionRegistry))
|
||||||
|
for k := range conditionRegistry {
|
||||||
|
types = append(types, k)
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyPanelBase копирует визуальную часть панели, убирая служебные поля шаблона
|
||||||
|
func copyPanelBase(panelMap map[string]interface{}, id, yPos, xPos, width int) map[string]interface{} {
|
||||||
|
panel := deepCopy(panelMap).(map[string]interface{})
|
||||||
|
delete(panel, "query_mode")
|
||||||
|
delete(panel, "query_config")
|
||||||
|
|
||||||
|
panel["id"] = id
|
||||||
|
gridPos, ok := panel["gridPos"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
gridPos = map[string]interface{}{}
|
||||||
|
panel["gridPos"] = gridPos
|
||||||
|
}
|
||||||
|
gridPos["y"] = yPos
|
||||||
|
gridPos["x"] = xPos
|
||||||
|
if width > 0 {
|
||||||
|
gridPos["w"] = width
|
||||||
|
}
|
||||||
|
return panel
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSIDQuery(sid string) string {
|
||||||
|
if strings.Contains(sid, " OR ") {
|
||||||
|
return fmt.Sprintf("SID:(%s)", sid)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("SID:%s", sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replacePlaceholders(s, sid, clientTitle string) string {
|
||||||
|
s = strings.ReplaceAll(s, "{sid}", sid)
|
||||||
|
s = strings.ReplaceAll(s, "{sids}", sid)
|
||||||
|
s = strings.ReplaceAll(s, "{client_title}", clientTitle)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBucketAgg(template *DashboardTemplate) map[string]interface{} {
|
||||||
|
return deepCopy(template.Queries["bucket_agg"]).(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeDataTarget(alias, query, refID string, metric map[string]interface{}, hidden bool, datasourceType, datasourceUID string, bucketAgg map[string]interface{}) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"alias": alias,
|
||||||
|
"bucketAggs": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"field": bucketAgg["field"],
|
||||||
|
"id": "2",
|
||||||
|
"settings": bucketAgg["settings"],
|
||||||
|
"type": bucketAgg["type"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"datasource": map[string]interface{}{"type": datasourceType, "uid": datasourceUID},
|
||||||
|
"format": "table",
|
||||||
|
"hide": hidden,
|
||||||
|
"luceneQueryType": "Metric",
|
||||||
|
"metrics": []interface{}{metric},
|
||||||
|
"query": query,
|
||||||
|
"queryType": "lucene",
|
||||||
|
"refId": refID,
|
||||||
|
"timeField": "@timestamp",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeMathTarget(expression, refID string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"datasource": map[string]interface{}{
|
||||||
|
"name": "Expression",
|
||||||
|
"type": "__expr__",
|
||||||
|
"uid": "__expr__",
|
||||||
|
},
|
||||||
|
"expression": expression,
|
||||||
|
"hide": false,
|
||||||
|
"refId": refID,
|
||||||
|
"type": "math",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getString(m map[string]interface{}, key string) string {
|
||||||
|
if v, ok := m[key].(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Строители targets ---
|
||||||
|
|
||||||
|
func buildRPSTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, template *DashboardTemplate, _ DomainInfo, client ClientData) []interface{} {
|
||||||
|
query := replacePlaceholders(getString(cfg, "base_query"), sidQuery, client.ClientTitle)
|
||||||
|
alias := replacePlaceholders(getString(cfg, "alias"), sidQuery, client.ClientTitle)
|
||||||
|
bucketAgg := getBucketAgg(template)
|
||||||
|
countMetric := map[string]interface{}{"id": "1", "type": "count"}
|
||||||
|
|
||||||
|
return []interface{}{
|
||||||
|
makeDataTarget(alias, query, "A", countMetric, true, datasourceType, datasourceUID, bucketAgg),
|
||||||
|
makeMathTarget(getString(cfg, "expression"), getString(cfg, "expression_ref_id")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMultiTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, template *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} {
|
||||||
|
targetsRaw, _ := cfg["targets"].([]interface{})
|
||||||
|
bucketAgg := getBucketAgg(template)
|
||||||
|
countMetric := map[string]interface{}{"id": "1", "type": "count"}
|
||||||
|
|
||||||
|
targets := []interface{}{}
|
||||||
|
for _, t := range targetsRaw {
|
||||||
|
tMap, ok := t.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query := sidQuery
|
||||||
|
if suffix := getString(tMap, "query_suffix"); suffix != "" {
|
||||||
|
query = sidQuery + " " + suffix
|
||||||
|
}
|
||||||
|
targets = append(targets, makeDataTarget(
|
||||||
|
getString(tMap, "alias"), query, getString(tMap, "ref_id"),
|
||||||
|
countMetric, false, datasourceType, datasourceUID, bucketAgg,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRangePercentTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, template *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} {
|
||||||
|
fieldFilter := getString(cfg, "field_filter")
|
||||||
|
totalRefID := getString(cfg, "total_ref_id")
|
||||||
|
rangeField := getString(cfg, "range_field")
|
||||||
|
rangesRaw, _ := cfg["ranges"].([]interface{})
|
||||||
|
bucketAgg := getBucketAgg(template)
|
||||||
|
countMetric := map[string]interface{}{"id": "1", "type": "count"}
|
||||||
|
|
||||||
|
totalQuery := sidQuery
|
||||||
|
if fieldFilter != "" {
|
||||||
|
totalQuery = sidQuery + " AND " + fieldFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := []interface{}{
|
||||||
|
makeDataTarget("Total", totalQuery, totalRefID, countMetric, true, datasourceType, datasourceUID, bucketAgg),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range rangesRaw {
|
||||||
|
rMap, ok := r.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := getString(rMap, "label")
|
||||||
|
refID := getString(rMap, "ref_id")
|
||||||
|
query := fmt.Sprintf("%s AND %s:[%s TO %s]", sidQuery, rangeField, getString(rMap, "from"), getString(rMap, "to"))
|
||||||
|
|
||||||
|
targets = append(targets,
|
||||||
|
makeDataTarget(label, query, refID, countMetric, true, datasourceType, datasourceUID, bucketAgg),
|
||||||
|
makeMathTarget(fmt.Sprintf("($%s / $%s) * 100", refID, totalRefID), label),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMultiSumExpressionTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, template *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} {
|
||||||
|
targetsRaw, _ := cfg["targets"].([]interface{})
|
||||||
|
bucketAgg := getBucketAgg(template)
|
||||||
|
|
||||||
|
targets := []interface{}{}
|
||||||
|
for _, t := range targetsRaw {
|
||||||
|
tMap, ok := t.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sumMetric := map[string]interface{}{"field": getString(tMap, "field"), "id": "1", "type": "sum"}
|
||||||
|
targets = append(targets,
|
||||||
|
makeDataTarget(getString(tMap, "alias"), sidQuery, getString(tMap, "ref_id"), sumMetric, true, datasourceType, datasourceUID, bucketAgg),
|
||||||
|
makeMathTarget(getString(tMap, "expression"), getString(tMap, "expression_ref_id")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSumExpressionTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, template *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} {
|
||||||
|
bucketAgg := getBucketAgg(template)
|
||||||
|
sumMetric := map[string]interface{}{"field": getString(cfg, "field"), "id": "1", "type": "sum"}
|
||||||
|
|
||||||
|
return []interface{}{
|
||||||
|
makeDataTarget("", sidQuery, getString(cfg, "ref_id"), sumMetric, true, datasourceType, datasourceUID, bucketAgg),
|
||||||
|
makeMathTarget(getString(cfg, "expression"), getString(cfg, "expression_ref_id")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildBucketLogsTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, _ *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} {
|
||||||
|
query := strings.ReplaceAll(getString(cfg, "base_query"), "{sid}", sidQuery)
|
||||||
|
return []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"bucketAggs": deepCopy(cfg["bucket_aggs"]),
|
||||||
|
"datasource": map[string]interface{}{"type": datasourceType, "uid": datasourceUID},
|
||||||
|
"format": "table",
|
||||||
|
"luceneQueryType": "Logs",
|
||||||
|
"metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}},
|
||||||
|
"query": query,
|
||||||
|
"queryType": "lucene",
|
||||||
|
"refId": "A",
|
||||||
|
"timeField": "@timestamp",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
264
state.go
Normal file
264
state.go
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
LastRun time.Time `json:"last_run"`
|
||||||
|
TemplateCommit string `json:"template_commit"`
|
||||||
|
TemplateVersion string `json:"template_version"`
|
||||||
|
ClientsHash string `json:"clients_hash"`
|
||||||
|
ClientCount int `json:"client_count"`
|
||||||
|
DomainCount int `json:"domain_count"`
|
||||||
|
SIDs []string `json:"sids"`
|
||||||
|
LastChangeReason string `json:"last_change_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateManager struct {
|
||||||
|
stateFile string
|
||||||
|
state *State
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStateManager(stateFile string) *StateManager {
|
||||||
|
return &StateManager{
|
||||||
|
stateFile: stateFile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *StateManager) Load() error {
|
||||||
|
data, err := os.ReadFile(sm.stateFile)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// First run - no state file
|
||||||
|
sm.state = &State{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to read state file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.state = &State{}
|
||||||
|
if err := json.Unmarshal(data, sm.state); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse state file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *StateManager) Save() error {
|
||||||
|
sm.state.LastRun = time.Now()
|
||||||
|
|
||||||
|
// Create directory if it doesn't exist
|
||||||
|
dir := filepath.Dir(sm.stateFile)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create state directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(sm.state, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(sm.stateFile, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write state file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *StateManager) CheckChanges(templateCommit, templateVersion string, clients map[string]ClientData) (bool, string) {
|
||||||
|
if sm.state == nil {
|
||||||
|
return true, "First run - no previous state"
|
||||||
|
}
|
||||||
|
|
||||||
|
reasons := []string{}
|
||||||
|
|
||||||
|
// Check template commit
|
||||||
|
if sm.state.TemplateCommit != templateCommit {
|
||||||
|
reasons = append(reasons, fmt.Sprintf("Template commit changed: %s -> %s",
|
||||||
|
truncateHash(sm.state.TemplateCommit), truncateHash(templateCommit)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check template version
|
||||||
|
if sm.state.TemplateVersion != templateVersion {
|
||||||
|
reasons = append(reasons, fmt.Sprintf("Template version changed: %s -> %s",
|
||||||
|
sm.state.TemplateVersion, templateVersion))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new hash
|
||||||
|
newHash := calculateClientsHash(clients)
|
||||||
|
if sm.state.ClientsHash != newHash {
|
||||||
|
reasons = append(reasons, "Database content changed")
|
||||||
|
|
||||||
|
// Get details about what changed
|
||||||
|
newSIDs, removedSIDs, totalNew := compareSIDs(sm.state.SIDs, extractSIDs(clients))
|
||||||
|
|
||||||
|
if len(newSIDs) > 0 {
|
||||||
|
reasons = append(reasons, fmt.Sprintf(" - New SIDs: %v", newSIDs))
|
||||||
|
}
|
||||||
|
if len(removedSIDs) > 0 {
|
||||||
|
reasons = append(reasons, fmt.Sprintf(" - Removed SIDs: %v", removedSIDs))
|
||||||
|
}
|
||||||
|
if totalNew == 0 && len(newSIDs) == 0 && len(removedSIDs) == 0 {
|
||||||
|
reasons = append(reasons, " - Data modified (domains, aliases, etc.)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check client count
|
||||||
|
newClientCount := len(clients)
|
||||||
|
if sm.state.ClientCount != newClientCount {
|
||||||
|
reasons = append(reasons, fmt.Sprintf("Client count changed: %d -> %d",
|
||||||
|
sm.state.ClientCount, newClientCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check domain count
|
||||||
|
newDomainCount := countDomains(clients)
|
||||||
|
if sm.state.DomainCount != newDomainCount {
|
||||||
|
reasons = append(reasons, fmt.Sprintf("Domain count changed: %d -> %d",
|
||||||
|
sm.state.DomainCount, newDomainCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
hasChanges := len(reasons) > 0
|
||||||
|
changeReason := ""
|
||||||
|
if hasChanges {
|
||||||
|
changeReason = fmt.Sprintf("Changes detected:\n %s", joinReasons(reasons))
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasChanges, changeReason
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *StateManager) Update(templateCommit, templateVersion string, clients map[string]ClientData, changeReason string) {
|
||||||
|
if sm.state == nil {
|
||||||
|
sm.state = &State{}
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.state.TemplateCommit = templateCommit
|
||||||
|
sm.state.TemplateVersion = templateVersion
|
||||||
|
sm.state.ClientsHash = calculateClientsHash(clients)
|
||||||
|
sm.state.ClientCount = len(clients)
|
||||||
|
sm.state.DomainCount = countDomains(clients)
|
||||||
|
sm.state.SIDs = extractSIDs(clients)
|
||||||
|
sm.state.LastChangeReason = changeReason
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateClientsHash(clients map[string]ClientData) string {
|
||||||
|
// Create a deterministic representation
|
||||||
|
type hashData struct {
|
||||||
|
ClientTitle string
|
||||||
|
Domains []struct {
|
||||||
|
SID string
|
||||||
|
DomainName string
|
||||||
|
Aliases string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var data []hashData
|
||||||
|
for _, client := range clients {
|
||||||
|
hd := hashData{ClientTitle: client.ClientTitle}
|
||||||
|
for _, domain := range client.Domains {
|
||||||
|
hd.Domains = append(hd.Domains, struct {
|
||||||
|
SID string
|
||||||
|
DomainName string
|
||||||
|
Aliases string
|
||||||
|
}{
|
||||||
|
SID: domain.SID,
|
||||||
|
DomainName: domain.DomainName,
|
||||||
|
Aliases: domain.Aliases,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Sort domains for consistent hash
|
||||||
|
sort.Slice(hd.Domains, func(i, j int) bool {
|
||||||
|
return hd.Domains[i].SID < hd.Domains[j].SID
|
||||||
|
})
|
||||||
|
data = append(data, hd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by client title for consistent hash
|
||||||
|
sort.Slice(data, func(i, j int) bool {
|
||||||
|
return data[i].ClientTitle < data[j].ClientTitle
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate hash
|
||||||
|
jsonData, _ := json.Marshal(data)
|
||||||
|
hash := sha256.Sum256(jsonData)
|
||||||
|
return fmt.Sprintf("%x", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSIDs(clients map[string]ClientData) []string {
|
||||||
|
sidMap := make(map[string]bool)
|
||||||
|
for _, client := range clients {
|
||||||
|
for _, domain := range client.Domains {
|
||||||
|
sidMap[domain.SID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sids := make([]string, 0, len(sidMap))
|
||||||
|
for sid := range sidMap {
|
||||||
|
sids = append(sids, sid)
|
||||||
|
}
|
||||||
|
sort.Strings(sids)
|
||||||
|
return sids
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareSIDs(oldSIDs, newSIDs []string) (added, removed []string, total int) {
|
||||||
|
oldMap := make(map[string]bool)
|
||||||
|
for _, sid := range oldSIDs {
|
||||||
|
oldMap[sid] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
newMap := make(map[string]bool)
|
||||||
|
for _, sid := range newSIDs {
|
||||||
|
newMap[sid] = true
|
||||||
|
if !oldMap[sid] {
|
||||||
|
added = append(added, sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sid := range oldSIDs {
|
||||||
|
if !newMap[sid] {
|
||||||
|
removed = append(removed, sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total = len(added) + len(removed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func countDomains(clients map[string]ClientData) int {
|
||||||
|
count := 0
|
||||||
|
for _, client := range clients {
|
||||||
|
count += len(client.Domains)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinReasons(reasons []string) string {
|
||||||
|
result := ""
|
||||||
|
for i, reason := range reasons {
|
||||||
|
if i > 0 {
|
||||||
|
result += "\n "
|
||||||
|
}
|
||||||
|
result += reason
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *StateManager) GetLastRun() time.Time {
|
||||||
|
if sm.state == nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return sm.state.LastRun
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *StateManager) GetStats() (clientCount, domainCount, sidCount int) {
|
||||||
|
if sm.state == nil {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
return sm.state.ClientCount, sm.state.DomainCount, len(sm.state.SIDs)
|
||||||
|
}
|
||||||
31
template.go
Normal file
31
template.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadTemplate(templatesPath string) (*DashboardTemplate, error) {
|
||||||
|
templateFile := filepath.Join(templatesPath, "dashboard_template.json")
|
||||||
|
|
||||||
|
data, err := os.ReadFile(templateFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read template file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var template DashboardTemplate
|
||||||
|
if err := json.Unmarshal(data, &template); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse template JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &template, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deepCopy(src interface{}) interface{} {
|
||||||
|
data, _ := json.Marshal(src)
|
||||||
|
var dst interface{}
|
||||||
|
json.Unmarshal(data, &dst)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue