grafana_gen/panels.go
2026-03-06 12:00:26 +03:00

461 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"crypto/md5"
"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,
"rps_var": buildRPSVarTargets,
"rps_multi": buildRPSMultiTargets,
"multi_target": buildMultiTargets,
"range_percent": buildRangePercentTargets,
"multi_sum_expression": buildMultiSumExpressionTargets,
"sum_expression": buildSumExpressionTargets,
"bucket_logs": buildBucketLogsTargets,
"static": buildStaticTargets,
}
// 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)
applyRPSThreshold(panel, panelKey, client.RPSLimit, client.RPSCommercialLimit)
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 проверяет условие отображения панели через реестр
// applyRPSThreshold подставляет rps_limit и rps_commercial_limit клиента в threshold панели rps.
// Красная линия — rps_limit (если 0, берётся из шаблона).
// Синяя линия — rps_commercial_limit (если 0, ставится 100).
func applyRPSThreshold(panel map[string]interface{}, panelKey string, rpsLimit, rpsCommercialLimit int) {
if panelKey != "rps" && panelKey != "rps_filtered" && panelKey != "rps_multi" {
return
}
// Ничего менять не нужно если оба лимита не заданы
if rpsLimit == 0 && rpsCommercialLimit == 0 {
return
}
fc, ok := panel["fieldConfig"].(map[string]interface{})
if !ok {
return
}
defaults, ok := fc["defaults"].(map[string]interface{})
if !ok {
return
}
thresholds, ok := defaults["thresholds"].(map[string]interface{})
if !ok {
return
}
// Коммерческий лимит: если не задан — 100
commercial := rpsCommercialLimit
if commercial == 0 {
commercial = 100
}
steps := []interface{}{
map[string]interface{}{"color": "green", "value": nil},
map[string]interface{}{"color": "blue", "value": commercial},
}
if rpsLimit > 0 {
steps = append(steps, map[string]interface{}{"color": "dark-red", "value": rpsLimit})
}
thresholds["steps"] = steps
// Включаем отображение линий если ещё не задано
custom, ok := defaults["custom"].(map[string]interface{})
if ok {
if _, hasStyle := custom["thresholdsStyle"]; !hasStyle {
custom["thresholdsStyle"] = map[string]interface{}{"mode": "line"}
}
}
}
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 makeMathTargetWithAlias(expression, refID, "")
}
func makeMathTargetWithAlias(expression, refID, alias string) map[string]interface{} {
t := map[string]interface{}{
"datasource": map[string]interface{}{
"name": "Expression",
"type": "__expr__",
"uid": "__expr__",
},
"expression": expression,
"hide": false,
"refId": refID,
"type": "math",
}
if alias != "" {
t["legendFormat"] = alias
}
return t
}
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")),
}
}
// buildRPSVarTargets — RPS с фильтрацией через Grafana-переменную $rps_sid.
// base_query может содержать ${rps_sid:lucene} напрямую — Go не трогает эти плейсхолдеры.
func buildRPSVarTargets(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)
// Уникальный суффикс на основе 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{})
if !ok {
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(alias, sidQuery, dataRefID, sumMetric, true, datasourceType, datasourceUID, bucketAgg),
makeMathTargetWithAlias(expr, exprRefID, alias),
)
}
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")),
}
}
// buildStaticTargets: панель без подстановки SID из БД.
// targets берутся из query_config.targets как есть — для глобальных/обзорных панелей.
// Поддерживает произвольные bucket_aggs из шаблона.
func buildStaticTargets(cfg map[string]interface{}, _ string, datasourceType, datasourceUID string, _ *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} {
targetsRaw, ok := cfg["targets"].([]interface{})
if !ok || len(targetsRaw) == 0 {
return []interface{}{}
}
// Копируем targets как есть, только проставляем datasource
targets := []interface{}{}
for _, t := range targetsRaw {
target := deepCopy(t).(map[string]interface{})
if _, hasDatasource := target["datasource"]; !hasDatasource {
target["datasource"] = map[string]interface{}{
"type": datasourceType,
"uid": datasourceUID,
}
}
targets = append(targets, target)
}
return targets
}
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",
},
}
}
// buildRPSMultiTargets — RPS с одной суммарной линией + по линии на каждый SID клиента.
func buildRPSMultiTargets(cfg map[string]interface{}, _ string, datasourceType, datasourceUID string, template *DashboardTemplate, _ DomainInfo, client ClientData) []interface{} {
bucketAgg := getBucketAgg(template)
countMetric := map[string]interface{}{"id": "1", "type": "count"}
expr := getString(cfg, "expression")
targets := []interface{}{}
// Суммарная линия по всем SID
allSIDs := buildTenantSIDQuery(client.Domains)
allQuery := fmt.Sprintf("SID:(%s)", allSIDs)
totalLabel := client.ClientTitle + " Total"
targets = append(targets,
makeDataTarget(totalLabel, allQuery, "A", countMetric, true, datasourceType, datasourceUID, bucketAgg),
makeMathTargetWithAlias(strings.ReplaceAll(expr, "$A", "$A"), "RPS_Total", totalLabel),
)
// Линия на каждый домен — refId используется как имя серии в Grafana
for i, domain := range client.Domains {
dataRefID := fmt.Sprintf("D%d", i)
// exprRefID = читаемое имя: "domain.name_SID" — Grafana показывает его в легенде
name := domain.SID
if domain.DomainName != "" {
// Заменяем пробелы и точки на _ для валидного refId
safeName := strings.NewReplacer(" ", "_", ".", "_").Replace(domain.DomainName)
name = fmt.Sprintf("%s_%s", safeName, domain.SID)
}
domainQuery := fmt.Sprintf("SID:%s", domain.SID)
domainExpr := strings.ReplaceAll(expr, "$A", "$"+dataRefID)
label := domain.SID
if domain.DomainName != "" {
label = fmt.Sprintf("%s %s", domain.DomainName, domain.SID)
}
targets = append(targets,
makeDataTarget(label, domainQuery, dataRefID, countMetric, true, datasourceType, datasourceUID, bucketAgg),
makeMathTargetWithAlias(domainExpr, name, label),
)
}
return targets
}