From 28b299fedd207738a94369a23fcafb9d6d009baa Mon Sep 17 00:00:00 2001 From: Magnus Root Date: Thu, 26 Feb 2026 15:35:00 +0300 Subject: [PATCH] Added multoresource --- alerts.go | 103 ++++++++++++++++++++++++++++++++++-- config.go | 13 +++-- dashboard.go | 146 ++++++++++++++++++++++++++++++++++++++++++++++----- panels.go | 24 +++++++-- 4 files changed, 260 insertions(+), 26 deletions(-) diff --git a/alerts.go b/alerts.go index a0eb83b..11e0805 100644 --- a/alerts.go +++ b/alerts.go @@ -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 != "" { diff --git a/config.go b/config.go index e502356..45a3d7d 100644 --- a/config.go +++ b/config.go @@ -130,11 +130,14 @@ type DashboardTemplate struct { } type LayoutConfig struct { - TenantPanels []PanelLayout `json:"tenant_panels"` - DomainPanels []PanelLayout `json:"domain_panels"` - UseCollapsedRows bool `json:"use_collapsed_rows"` - OverviewPanels []PanelLayout `json:"overview_panels"` // Панели обзора перед клиентскими строками - OverviewRowTitle string `json:"overview_row_title"` // Заголовок collapsed row обзора + 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 обзора } type PanelLayout struct { diff --git a/dashboard.go b/dashboard.go index 4e1e3cf..f27483f 100644 --- a/dashboard.go +++ b/dashboard.go @@ -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 diff --git a/panels.go b/panels.go index e1037d1..43a6e0c 100644 --- a/panels.go +++ b/panels.go @@ -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