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