package main import ( "fmt" "log" "strings" ) // generateVLDashboard создаёт дашборд для VictoriaLogs datasource. // Использует те же панели и layout что и основной дашборд, // но targets строятся через queryModeRegistryVL с LogsQL запросами. func generateVLDashboard(clients map[string]ClientData, template *DashboardTemplate, config Config) map[string]interface{} { panels := []interface{}{} yPos := 0 panelID := 100 datasourceUID := config.VLDatasourceUID datasourceType := DefaultVLDatasourceType useCollapsedRows := template.Layout.UseCollapsedRows if len(template.Layout.OverviewPanels) > 0 { overview := createOverviewRowVL(template, datasourceType, datasourceUID, panelID, yPos) panels = append(panels, overview) panelID += 50 yPos++ } for _, client := range clients { if len(client.Domains) == 0 { continue } if useCollapsedRows { row := createCollapsedRowVL(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 := generateLayoutPanelsVL( 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 := generateLayoutPanelsVL( template.Layout.DomainPanels, client, &domain, template, datasourceType, datasourceUID, panelID, yPos, ) panels = append(panels, domainPanels...) panelID = nextID yPos = nextY } interPanels, nextID, nextY := generateLayoutPanelsVL( 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 := generateLayoutPanelsVL( template.Layout.TrafficDomainPanels, client, &domain, template, datasourceType, datasourceUID, panelID, yPos, ) panels = append(panels, trafficPanels...) panelID = nextID yPos = nextY } } } title := config.VLDashboardTitle dashboard := buildDashboardJSON(panels, title, template, config.VLDashboardUID) return dashboard } // generateLayoutPanelsVL — аналог generateLayoutPanels но использует VL builders. func generateLayoutPanelsVL( layouts []PanelLayout, client ClientData, domain *DomainInfo, template *DashboardTemplate, datasourceType, datasourceUID string, panelID, yPos int, ) ([]interface{}, int, int) { panels := []interface{}{} for _, layout := range layouts { if layout.Condition != nil { evaluator, ok := conditionRegistry[layout.Condition.Type] if !ok || !evaluator(layout.Condition.Value, client, domain) { continue } } domainInfo := DomainInfo{} if domain != nil { domainInfo = *domain } width := layout.Width if width == 0 { // Берём ширину из gridPos шаблона панели if panelDef, ok := template.Panels[layout.PanelKey]; ok { if pm, ok := panelDef.(map[string]interface{}); ok { if gp, ok := pm["gridPos"].(map[string]interface{}); ok { if w, ok := gp["w"].(float64); ok && w > 0 { width = int(w) } } } } } if width == 0 { width = 12 } xPos := layout.XOffset panel := buildPanelVL(layout.PanelKey, template, domainInfo, client, datasourceType, datasourceUID, panelID, yPos, xPos, width) if panel == nil { continue } // Применяем title format или title suffix if layout.TitleFormat != "" { panel["title"] = formatPanelTitle(layout.TitleFormat, client, domainInfo) } else if layout.TitleSuffix != "" && domainInfo.DomainName != "" { panel["title"] = fmt.Sprintf("%s %s", domainInfo.DomainName, formatPanelTitle(layout.TitleSuffix, client, domainInfo)) } else if layout.TitleSuffix != "" { panel["title"] = formatPanelTitle(layout.TitleSuffix, client, domainInfo) } panels = append(panels, panel) panelID++ // Увеличиваем yPos только если следующая панель не рядом (другой x) if xPos == 0 || xPos+width >= 24 { yPos += getGridH(panel) } } return panels, panelID, yPos } // buildPanelVL — аналог buildPanel но использует queryModeRegistryVL. func buildPanelVL( 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 } panel := deepCopyMap(panelMap) // gridPos h := 8 if gridPos, ok := panel["gridPos"].(map[string]interface{}); ok { if hVal, ok := gridPos["h"].(float64); ok { h = int(hVal) } } panel["gridPos"] = map[string]interface{}{ "h": h, "w": width, "x": xPos, "y": yPos, } panel["id"] = id // Подменяем datasource panel["datasource"] = map[string]interface{}{ "type": datasourceType, "uid": datasourceUID, } // Строим targets через VL registry queryMode, _ := panel["query_mode"].(string) if queryMode == "" || queryMode == "row" { return panel } builder, ok := queryModeRegistryVL[queryMode] if !ok { log.Printf("Warning VL: unknown query_mode '%s' for panel '%s'", queryMode, panelKey) return panel } queryConfigRaw, _ := panel["query_config"].(map[string]interface{}) if queryConfigRaw == nil { queryConfigRaw = map[string]interface{}{} } // Строим sidQuery в LogsQL формате var sidQuery string if domain.SID != "" { sidQuery = fmt.Sprintf("SID:%s", domain.SID) } else { sidQuery = buildTenantSIDQueryVL(client.Domains) } panel["targets"] = builder(queryConfigRaw, sidQuery, datasourceType, datasourceUID, template, domain, client) // Минимальный интервал агрегации для панелей где нужна агрегация по минутам // Читаем из шаблона панели поле vl_interval, иначе не устанавливаем (auto) if vlInterval, ok := panel["vl_interval"].(string); ok && vlInterval != "" { panel["interval"] = vlInterval } delete(panel, "vl_interval") // Сдвиг на 1 минуту назад чтобы скрыть неполную последнюю минуту panel["timeShift"] = "1m" // Подставляем плейсхолдеры в title панели из шаблона if title, ok := panel["title"].(string); ok { panel["title"] = formatPanelTitle(title, client, domain) } // Для range_percent добавляем transformation которая убирает " count(*)" из названий серий if queryMode == "range_percent" { panel["transformations"] = []interface{}{ map[string]interface{}{ "id": "renameByRegex", "options": map[string]interface{}{ "regex": "^(.+) count[(][*][)]$", "renamePattern": "$1", }, }, } } // Применяем RPS threshold (те же данные) applyRPSThreshold(panel, panelKey, client.RPSLimit, client.RPSCommercialLimit) return panel } // createOverviewRowVL — аналог createOverviewRow для VL. func createOverviewRowVL(template *DashboardTemplate, datasourceType, datasourceUID string, panelID, yPos int) map[string]interface{} { overviewPanels := []interface{}{} dummyClient := ClientData{} for _, layout := range template.Layout.OverviewPanels { width := layout.Width if width == 0 { width = 24 } panel := buildPanelVL(layout.PanelKey, template, DomainInfo{}, dummyClient, datasourceType, datasourceUID, panelID, 1, layout.XOffset, width) if panel == nil { continue } if layout.TitleFormat != "" { panel["title"] = layout.TitleFormat } overviewPanels = append(overviewPanels, panel) panelID++ } rowTitle := template.Layout.OverviewRowTitle if rowTitle == "" { rowTitle = "Обзор" } return map[string]interface{}{ "type": "row", "title": rowTitle, "id": panelID, "collapsed": true, "gridPos": map[string]interface{}{"h": 1, "w": 24, "x": 0, "y": yPos}, "panels": overviewPanels, } } // createCollapsedRowVL — аналог createCollapsedRow для VL. func createCollapsedRowVL(client ClientData, template *DashboardTemplate, datasourceType, datasourceUID string, panelID, yPos int) map[string]interface{} { innerPanels := []interface{}{} currentY := 1 addPanelVL := func(layouts []PanelLayout, domain *DomainInfo, rowStartY int) { for _, layout := range layouts { if layout.Condition != nil { evaluator, ok := conditionRegistry[layout.Condition.Type] if !ok || !evaluator(layout.Condition.Value, client, domain) { continue } } domainInfo := DomainInfo{} if domain != nil { domainInfo = *domain } width := layout.Width if width == 0 { if panelDef, ok := template.Panels[layout.PanelKey]; ok { if pm, ok := panelDef.(map[string]interface{}); ok { if gp, ok := pm["gridPos"].(map[string]interface{}); ok { if w, ok := gp["w"].(float64); ok && w > 0 { width = int(w) } } } } } if width == 0 { width = 12 } xPos := layout.XOffset panel := buildPanelVL(layout.PanelKey, template, domainInfo, client, datasourceType, datasourceUID, panelID, rowStartY, xPos, width) if panel == nil { continue } if layout.TitleFormat != "" { panel["title"] = formatPanelTitle(layout.TitleFormat, client, domainInfo) } else if layout.TitleSuffix != "" && domainInfo.DomainName != "" { panel["title"] = fmt.Sprintf("%s %s", domainInfo.DomainName, formatPanelTitle(layout.TitleSuffix, client, domainInfo)) } else if layout.TitleSuffix != "" { panel["title"] = formatPanelTitle(layout.TitleSuffix, client, domainInfo) } innerPanels = append(innerPanels, panel) panelID++ } } // tenant_panels addPanelVL(template.Layout.TenantPanels, nil, currentY) currentY += 8 // domain_panels (side by side) for _, domain := range client.Domains { d := domain addPanelVL(template.Layout.DomainPanels, &d, currentY) currentY += 8 } // inter_domain_panels if len(template.Layout.InterDomainPanels) > 0 { addPanelVL(template.Layout.InterDomainPanels, nil, currentY) currentY += 8 } // traffic_domain_panels for _, domain := range client.Domains { d := domain addPanelVL(template.Layout.TrafficDomainPanels, &d, currentY) currentY += 8 } return map[string]interface{}{ "type": "row", "title": client.ClientTitle, "id": panelID, "collapsed": true, "gridPos": map[string]interface{}{"h": 1, "w": 24, "x": 0, "y": yPos}, "panels": innerPanels, } } // getGridH возвращает высоту панели из gridPos. func getGridH(panel map[string]interface{}) int { if gp, ok := panel["gridPos"].(map[string]interface{}); ok { if h, ok := gp["h"].(int); ok { return h } if h, ok := gp["h"].(float64); ok { return int(h) } } return 8 } // formatPanelTitle форматирует заголовок панели подстановкой переменных. func formatPanelTitle(format string, client ClientData, domain DomainInfo) string { title := format title = strings.ReplaceAll(title, "{client}", client.ClientTitle) title = strings.ReplaceAll(title, "{domain}", domain.DomainName) title = strings.ReplaceAll(title, "{sid}", domain.SID) return title } // sendVLDashboardToGrafana отправляет VL дашборд в Grafana. func sendVLDashboardToGrafana(dashboard map[string]interface{}, config Config) error { vlConfig := config if config.VLGrafanaFolder != "" { vlConfig.GrafanaFolder = config.VLGrafanaFolder } // Используем тот же sendToGrafana return sendToGrafana(dashboard, vlConfig) }