Added multoresource

This commit is contained in:
Magnus Root 2026-02-26 15:35:00 +03:00
parent 7b711c2317
commit 28b299fedd
4 changed files with 260 additions and 26 deletions

103
alerts.go
View file

@ -20,6 +20,13 @@ type AlertRulesTemplate struct {
Defaults AlertDefaults `json:"defaults"`
RPSAlert map[string]interface{} `json:"rps_alert"`
StaticAlerts []map[string]interface{} `json:"static_alerts"` // Алерты без привязки к БД
ContactPoint ContactPointConfig `json:"contact_point"` // Настройки шаблона уведомлений
}
// ContactPointConfig описывает кастомный шаблон сообщения для Contact Point
type ContactPointConfig struct {
ReceiverName string `json:"receiver_name"` // Имя contact point в Grafana
MessageTemplate string `json:"message_template"` // Go-шаблон сообщения Telegram
}
type AlertDefaults struct {
@ -86,7 +93,7 @@ func buildRPSAlertRule(client ClientData, tmpl AlertRulesTemplate, config Config
uid := generateAlertUID(client.ClientTitle, "rps")
dataRefID := "A"
rpsRefID := "RPS " + client.ClientTitle
reduceRefID := "Текущий RPS"
reduceRefID := "Значение"
thresholdRefID := "Превышение"
rule := map[string]interface{}{
@ -364,6 +371,11 @@ func generateAndSendAlerts(clients map[string]ClientData, tmpl AlertRulesTemplat
log.Printf("Warning: static alerts error: %v", err)
}
// Обновляем шаблон сообщения Contact Point
if err := upsertContactPoint(tmpl, config, dryRun); err != nil {
log.Printf("Warning: failed to update contact point message template: %v", err)
}
return nil
}
@ -401,9 +413,9 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
uid := generateAlertUID(client.ClientTitle, codeName)
totalRefID := "Total"
errRefID := "Errors"
totalReduceID := "TotalReduce"
errReduceID := "ErrorsReduce"
percentRefID := "ErrorPercent"
totalReduceID := "_TotalReduce"
errReduceID := "_ErrorsReduce"
percentRefID := "Значение"
thresholdRefID := "Превышение"
summary := fmt.Sprintf("🚨 %s — %s ошибок >%d%% трафика", client.ClientTitle, codeName, limitPct)
@ -737,6 +749,89 @@ func ensureAlertFolder(config Config) (string, error) {
return "", fmt.Errorf("failed to get folder UID")
}
// upsertContactPoint обновляет шаблон сообщения у существующего Contact Point.
// Не создаёт contact point — только обновляет message у уже настроенного в Grafana.
func upsertContactPoint(tmpl AlertRulesTemplate, config Config, dryRun bool) error {
if tmpl.ContactPoint.ReceiverName == "" || tmpl.ContactPoint.MessageTemplate == "" {
return nil
}
receiverName := orDefault(config.AlertsReceiver, tmpl.ContactPoint.ReceiverName)
baseURL := strings.TrimRight(config.GrafanaURL, "/")
// Получить список contact points и найти нужный по имени
req, _ := http.NewRequest("GET", baseURL+"/api/v1/provisioning/contact-points", nil)
req.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to list contact points: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var contactPoints []map[string]interface{}
if err := json.Unmarshal(body, &contactPoints); err != nil {
return fmt.Errorf("failed to parse contact points: %w", err)
}
// Ищем contact point с нужным именем
var target map[string]interface{}
for _, cp := range contactPoints {
if name, _ := cp["name"].(string); name == receiverName {
target = cp
break
}
}
if target == nil {
log.Printf("Contact point '%s' not found, skipping message template update", receiverName)
return nil
}
uid, _ := target["uid"].(string)
// Contact point — одиночный объект с полем type и settings
if cpType, _ := target["type"].(string); cpType != "telegram" {
log.Printf("Contact point '%s' is not Telegram (type: %s), skipping", receiverName, cpType)
return nil
}
settings, ok := target["settings"].(map[string]interface{})
if !ok {
settings = make(map[string]interface{})
target["settings"] = settings
}
settings["message"] = tmpl.ContactPoint.MessageTemplate
if dryRun {
data, _ := json.MarshalIndent(target, "", " ")
log.Printf("DRY RUN: Contact point update for '%s':\n%s", receiverName, string(data))
return nil
}
// PUT /api/v1/provisioning/contact-points/{uid}
payload, _ := json.Marshal(target)
updReq, _ := http.NewRequest("PUT", baseURL+"/api/v1/provisioning/contact-points/"+uid, bytes.NewBuffer(payload))
updReq.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey)
updReq.Header.Set("Content-Type", "application/json")
updReq.Header.Set("X-Disable-Provenance", "true")
updResp, err := http.DefaultClient.Do(updReq)
if err != nil {
return fmt.Errorf("failed to update contact point: %w", err)
}
defer updResp.Body.Close()
respBody, _ := io.ReadAll(updResp.Body)
if updResp.StatusCode < 200 || updResp.StatusCode >= 300 {
return fmt.Errorf("contact point update failed: status %d: %s", updResp.StatusCode, string(respBody))
}
log.Printf("Contact point '%s' message template updated", receiverName)
return nil
}
// orDefault возвращает val если не пустой, иначе def
func orDefault(val, def string) string {
if val != "" {

View file

@ -131,7 +131,10 @@ type DashboardTemplate struct {
type LayoutConfig struct {
TenantPanels []PanelLayout `json:"tenant_panels"`
PreDomainPanels []PanelLayout `json:"pre_domain_panels"` // Панели один раз перед всеми доменными
DomainPanels []PanelLayout `json:"domain_panels"`
InterDomainPanels []PanelLayout `json:"inter_domain_panels"` // Панели один раз между группами доменных
TrafficDomainPanels []PanelLayout `json:"traffic_domain_panels"` // Панели трафика per-domain после inter_domain
UseCollapsedRows bool `json:"use_collapsed_rows"`
OverviewPanels []PanelLayout `json:"overview_panels"` // Панели обзора перед клиентскими строками
OverviewRowTitle string `json:"overview_row_title"` // Заголовок collapsed row обзора

View file

@ -17,6 +17,14 @@ func generateSingleDashboard(clients map[string]ClientData, template *DashboardT
datasourceUID := template.Datasource["uid"].(string)
useCollapsedRows := template.Layout.UseCollapsedRows
// Генерация overview row (глобальные панели перед клиентскими строками)
if len(template.Layout.OverviewPanels) > 0 {
overview := createOverviewRow(template, datasourceType, datasourceUID, panelID, yPos)
panels = append(panels, overview)
panelID += 50
yPos++
}
for _, client := range clients {
if len(client.Domains) == 0 {
continue
@ -49,6 +57,24 @@ func generateSingleDashboard(clients map[string]ClientData, template *DashboardT
panelID = nextID
yPos = nextY
}
interPanels, nextID, nextY := generateLayoutPanels(
template.Layout.InterDomainPanels, client, nil,
template, datasourceType, datasourceUID, panelID, yPos,
)
panels = append(panels, interPanels...)
panelID = nextID
yPos = nextY
for _, domain := range client.Domains {
trafficPanels, nextID, nextY := generateLayoutPanels(
template.Layout.TrafficDomainPanels, client, &domain,
template, datasourceType, datasourceUID, panelID, yPos,
)
panels = append(panels, trafficPanels...)
panelID = nextID
yPos = nextY
}
}
}
@ -87,6 +113,11 @@ func applyDashboardMeta(dashboardBase map[string]interface{}, template *Dashboar
if version, ok := meta["version"]; ok {
dashboardBase["version"] = version
}
// Переменные дашборда (templating) — переносим из шаблона если заданы
if template.Templating != nil {
dashboardBase["templating"] = deepCopy(template.Templating)
}
}
// createCollapsedRow создаёт row с панелями внутри (collapsed=true)
@ -106,42 +137,84 @@ func createCollapsedRow(
insidePanels := []interface{}{}
panelID := startPanelID + 1
currentY := 1 // последовательный y для правильного порядка внутри collapsed row
// Вспомогательная функция: добавить панель с текущим y и обновить currentY
addPanel := func(panel map[string]interface{}, h int) {
if gp, ok := panel["gridPos"].(map[string]interface{}); ok {
gp["y"] = currentY
}
insidePanels = append(insidePanels, panel)
panelID++
}
nextRow := func(h int) { currentY += h }
// Tenant panels (RPS, Status Codes)
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,
)
panel := buildPanel(layout.PanelKey, template, domain, client,
datasourceType, datasourceUID, panelID, currentY, layout.XOffset, layout.Width)
if panel == nil {
continue
}
applyTitle(panel, layout, client.ClientTitle, "", "")
insidePanels = append(insidePanels, panel)
panelID++
addPanel(panel, 8)
}
nextRow(8)
// Domain panels — status/response per domain
for _, domain := range client.Domains {
rowStart := currentY
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,
)
panel := buildPanel(layout.PanelKey, template, domain, client,
datasourceType, datasourceUID, panelID, rowStart, 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++
addPanel(panel, 8)
}
currentY = rowStart + 8
}
// Inter-domain панели — Total Traffic (один раз)
for _, layout := range template.Layout.InterDomainPanels {
if !evaluateCondition(layout.Condition, client, nil) {
continue
}
domain := tenantDomain(client)
panel := buildPanel(layout.PanelKey, template, domain, client,
datasourceType, datasourceUID, panelID, currentY, layout.XOffset, layout.Width)
if panel == nil {
continue
}
applyTitle(panel, layout, client.ClientTitle, "", "")
addPanel(panel, 8)
currentY += 8
}
// Traffic domain panels — трафик per domain
for _, domain := range client.Domains {
for _, layout := range template.Layout.TrafficDomainPanels {
if !evaluateCondition(layout.Condition, client, &domain) {
continue
}
panel := buildPanel(layout.PanelKey, template, domain, client,
datasourceType, datasourceUID, panelID, currentY, layout.XOffset, layout.Width)
if panel == nil {
continue
}
applyTitle(panel, layout, client.ClientTitle, domain.DomainName, domain.SID)
panel["description"] = domain.Aliases
addPanel(panel, 8)
currentY += 8
}
}
@ -234,6 +307,51 @@ func buildTenantSIDQuery(domains []DomainInfo) string {
return strings.Join(sids, " OR ")
}
// createOverviewRow создаёт collapsed row с глобальными обзорными панелями.
// Панели типа "static" — копируются из шаблона как есть, без подстановки SID из БД.
func createOverviewRow(
template *DashboardTemplate,
datasourceType, datasourceUID string,
startPanelID, yPos int,
) map[string]interface{} {
title := template.Layout.OverviewRowTitle
if title == "" {
title = "Обзор"
}
row := deepCopy(template.Panels["row"]).(map[string]interface{})
row["id"] = startPanelID
row["title"] = title
row["collapsed"] = true
row["gridPos"].(map[string]interface{})["y"] = yPos
insidePanels := []interface{}{}
panelID := startPanelID + 1
// Пустой клиент и домен — static-панели их не используют
emptyClient := ClientData{}
emptyDomain := DomainInfo{}
for _, layout := range template.Layout.OverviewPanels {
panel := buildPanel(
layout.PanelKey, template, emptyDomain, emptyClient,
datasourceType, datasourceUID,
panelID, layout.YOffset, layout.XOffset, layout.Width,
)
if panel == nil {
continue
}
if layout.TitleFormat != "" || layout.TitleSuffix != "" {
applyTitle(panel, layout, "", "", "")
}
insidePanels = append(insidePanels, panel)
panelID++
}
row["panels"] = insidePanels
return row
}
func createRowPanel(title string, template *DashboardTemplate, id, yPos int) map[string]interface{} {
row := deepCopy(template.Panels["row"]).(map[string]interface{})
row["id"] = id

View file

@ -1,6 +1,7 @@
package main
import (
"crypto/md5"
"fmt"
"log"
"strings"
@ -226,7 +227,11 @@ func makeDataTarget(alias, query, refID string, metric map[string]interface{}, h
}
func makeMathTarget(expression, refID string) map[string]interface{} {
return map[string]interface{}{
return makeMathTargetWithAlias(expression, refID, "")
}
func makeMathTargetWithAlias(expression, refID, alias string) map[string]interface{} {
t := map[string]interface{}{
"datasource": map[string]interface{}{
"name": "Expression",
"type": "__expr__",
@ -237,6 +242,10 @@ func makeMathTarget(expression, refID string) map[string]interface{} {
"refId": refID,
"type": "math",
}
if alias != "" {
t["legendFormat"] = alias
}
return t
}
func getString(m map[string]interface{}, key string) string {
@ -321,6 +330,10 @@ func buildMultiSumExpressionTargets(cfg map[string]interface{}, sidQuery, dataso
targetsRaw, _ := cfg["targets"].([]interface{})
bucketAgg := getBucketAgg(template)
// Уникальный суффикс на основе sidQuery чтобы избежать конфликта refId на дашборде
h := md5.Sum([]byte(sidQuery))
uidSuffix := fmt.Sprintf("%x", h[:3])
targets := []interface{}{}
for _, t := range targetsRaw {
tMap, ok := t.(map[string]interface{})
@ -328,9 +341,14 @@ func buildMultiSumExpressionTargets(cfg map[string]interface{}, sidQuery, dataso
continue
}
sumMetric := map[string]interface{}{"field": getString(tMap, "field"), "id": "1", "type": "sum"}
alias := getString(tMap, "alias")
dataRefID := getString(tMap, "ref_id") + "_" + uidSuffix
exprRefID := getString(tMap, "expression_ref_id") + "_" + uidSuffix
// Подставляем оригинальный ref_id в expression и заменяем на уникальный
expr := strings.ReplaceAll(getString(tMap, "expression"), "$"+getString(tMap, "ref_id"), "$"+dataRefID)
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")),
makeDataTarget(alias, sidQuery, dataRefID, sumMetric, true, datasourceType, datasourceUID, bucketAgg),
makeMathTargetWithAlias(expr, exprRefID, alias),
)
}
return targets