Added multoresource
This commit is contained in:
parent
7b711c2317
commit
28b299fedd
4 changed files with 260 additions and 26 deletions
103
alerts.go
103
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 != "" {
|
||||
|
|
|
|||
|
|
@ -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 обзора
|
||||
|
|
|
|||
146
dashboard.go
146
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
|
||||
|
|
|
|||
24
panels.go
24
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue