Work version

This commit is contained in:
Magnus Root 2026-02-24 12:45:35 +03:00
commit 692fa563e1
13 changed files with 2301 additions and 0 deletions

46
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}