grafana/apps/dashboard/pkg/migration/schemaversion/v34.go

358 lines
10 KiB
Go

package schemaversion
import "context"
// V34 migrates CloudWatch queries that use multiple statistics into separate queries.
//
// This migration addresses CloudWatch queries where a single query uses multiple statistics
// (e.g., statistics: ['Max', 'Min']). The migration splits these into separate queries,
// each with a single statistic (e.g., one query with statistic: 'Max', another with statistic: 'Min').
//
// The migration works by:
// 1. Identifying CloudWatch queries in panel targets that have a 'statistics' array
// 2. Creating separate queries for each statistic in the array
// 3. Replacing the original 'statistics' array with a single 'statistic' field
// 4. Generating new refIds for additional queries (B, C, D, etc.)
// 5. Applying the same logic to CloudWatch annotation queries
// 6. Adding statistic suffixes to annotation names when multiple annotations are created
//
// Panel Query Example - Multiple Statistics:
//
// Before migration:
//
// target: {
// refId: "A",
// dimensions: {"InstanceId": "i-123"},
// namespace: "AWS/EC2",
// region: "us-east-1",
// metricName: "CPUUtilization",
// statistics: ["Average", "Maximum", "Minimum"]
// }
//
// After migration:
//
// targets: [
// { refId: "A", dimensions: {"InstanceId": "i-123"}, namespace: "AWS/EC2", region: "us-east-1", metricName: "CPUUtilization", statistic: "Average" },
// { refId: "B", dimensions: {"InstanceId": "i-123"}, namespace: "AWS/EC2", region: "us-east-1", metricName: "CPUUtilization", statistic: "Maximum" },
// { refId: "C", dimensions: {"InstanceId": "i-123"}, namespace: "AWS/EC2", region: "us-east-1", metricName: "CPUUtilization", statistic: "Minimum" }
// ]
//
// Annotation Query Example - Multiple Statistics:
// Before migration:
//
// annotation: {
// name: "CloudWatch Alerts",
// dimensions: {"InstanceId": "i-123"},
// namespace: "AWS/EC2",
// region: "us-east-1",
// prefixMatching: false,
// statistics: ["Maximum", "Minimum"]
// }
//
// After migration:
//
// annotations: [
// { name: "CloudWatch Alerts - Maximum", dimensions: {"InstanceId": "i-123"}, namespace: "AWS/EC2", region: "us-east-1", prefixMatching: false, statistic: "Maximum" },
// { name: "CloudWatch Alerts - Minimum", dimensions: {"InstanceId": "i-123"}, namespace: "AWS/EC2", region: "us-east-1", prefixMatching: false, statistic: "Minimum" }
// ]
func V34(_ context.Context, dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = int(34)
// Migrate panel queries if panels exist and are an array
if panelsValue, exists := dashboard["panels"]; exists && IsArray(panelsValue) {
panels := panelsValue.([]interface{})
for _, panel := range panels {
p, ok := panel.(map[string]interface{})
if !ok {
continue
}
migrateCloudWatchQueriesInPanel(p)
// Handle nested panels in collapsed rows
if !IsArray(p["panels"]) {
continue
}
nestedPanels := p["panels"].([]interface{})
for _, nestedPanel := range nestedPanels {
np, ok := nestedPanel.(map[string]interface{})
if !ok {
continue
}
migrateCloudWatchQueriesInPanel(np)
}
}
}
// Always migrate annotation queries regardless of whether panels exist
migrateCloudWatchAnnotationQueries(dashboard)
return nil
}
// migrateCloudWatchQueriesInPanel migrates CloudWatch queries within a panel that use multiple statistics.
func migrateCloudWatchQueriesInPanel(panel map[string]interface{}) {
if !IsArray(panel["targets"]) {
return
}
targets := panel["targets"].([]interface{})
var newTargets []interface{}
var additionalTargets []interface{}
for _, target := range targets {
t, ok := target.(map[string]interface{})
if !ok {
newTargets = append(newTargets, target)
continue
}
if !isCloudWatchQuery(t) {
newTargets = append(newTargets, target)
continue
}
// Add CloudWatch fields if missing (set to 0 if not present)
t["metricEditorMode"] = GetIntValue(t, "metricEditorMode", 0)
t["metricQueryType"] = GetIntValue(t, "metricQueryType", 0)
// Get valid statistics (including null and empty strings)
validStats, isEmpty := getValidStatistics(t["statistics"])
// Handle empty array case (delete statistics field like frontend)
if isEmpty {
// Delete statistics field to match frontend behavior
delete(t, "statistics")
newTargets = append(newTargets, t)
continue
}
// Remove statistics field for processing
delete(t, "statistics")
// Handle based on number of valid statistics
switch len(validStats) {
case 0:
// No valid statistics - keep query as-is
newTargets = append(newTargets, t)
case 1:
// Single statistic - set statistic field if not null
if statString := GetStringValue(map[string]interface{}{"stat": validStats[0]}, "stat"); statString != "" {
t["statistic"] = statString
}
newTargets = append(newTargets, t)
default:
// Multiple statistics - create separate queries
for i, stat := range validStats {
newQuery := copyMap(t)
if stat != nil {
if statString, ok := stat.(string); ok {
newQuery["statistic"] = statString
}
}
if i == 0 {
newTargets = append(newTargets, newQuery)
} else {
newQuery["refId"] = generateNextRefId(append(targets, additionalTargets...), len(additionalTargets))
additionalTargets = append(additionalTargets, newQuery)
}
}
}
}
panel["targets"] = append(newTargets, additionalTargets...)
}
// migrateCloudWatchAnnotationQueries migrates CloudWatch annotation queries that use multiple statistics.
func migrateCloudWatchAnnotationQueries(dashboard map[string]interface{}) {
annotations, ok := dashboard["annotations"].(map[string]interface{})
if !ok {
return
}
if !IsArray(annotations["list"]) {
return
}
annotationsList := annotations["list"].([]interface{})
var additionalAnnotations []interface{}
for i, annotation := range annotationsList {
a, ok := annotation.(map[string]interface{})
if !ok {
continue
}
if !isLegacyCloudWatchAnnotationQuery(a) {
continue
}
// Get original name for suffix generation
originalName := GetStringValue(a, "name")
// Get valid statistics (including null and empty strings)
validStats, isEmpty := getValidStatistics(a["statistics"])
// Handle empty array case (delete statistics field like frontend)
if isEmpty {
// Delete statistics field to match frontend behavior
delete(a, "statistics")
annotationsList[i] = a
continue
}
// Handle based on number of valid statistics
switch len(validStats) {
case 0:
// No valid statistics - remove statistics field
delete(a, "statistics")
annotationsList[i] = a
case 1:
// Single statistic - set statistic field if not null
delete(a, "statistics")
if statString := GetStringValue(map[string]interface{}{"stat": validStats[0]}, "stat"); statString != "" {
a["statistic"] = statString
}
annotationsList[i] = a
default:
// Multiple statistics - create separate annotations
delete(a, "statistics")
for j, stat := range validStats {
newAnnotation := copyMap(a)
if stat != nil {
if statString, ok := stat.(string); ok {
newAnnotation["statistic"] = statString
}
}
// Add suffix to name
if originalName != "" {
suffix := getSuffixForStat(stat)
newAnnotation["name"] = originalName + " - " + suffix
}
if j == 0 {
annotationsList[i] = newAnnotation
} else {
additionalAnnotations = append(additionalAnnotations, newAnnotation)
}
}
}
}
if len(additionalAnnotations) > 0 {
annotations["list"] = append(annotationsList, additionalAnnotations...)
}
}
// getValidStatistics extracts valid statistics from the statistics field
func getValidStatistics(statisticsField interface{}) ([]interface{}, bool) {
statistics, ok := statisticsField.([]interface{})
if !ok {
return nil, false
}
// Special case: empty arrays should be preserved
if len(statistics) == 0 {
return nil, true // Return nil with true flag to indicate "empty array"
}
var valid []interface{}
for _, stat := range statistics {
// Include null and strings (including empty strings)
if stat == nil || isString(stat) {
valid = append(valid, stat)
}
}
return valid, false
}
// getSuffixForStat returns the appropriate suffix for annotation names
func getSuffixForStat(stat interface{}) string {
if stat == nil {
return "null"
}
if statString, ok := stat.(string); ok {
if statString == "" {
return ""
}
return statString
}
return ""
}
// copyMap creates a shallow copy of a map
func copyMap(original map[string]interface{}) map[string]interface{} {
copy := make(map[string]interface{})
for k, v := range original {
copy[k] = v
}
return copy
}
// isString checks if value is a string
func isString(value interface{}) bool {
_, ok := value.(string)
return ok
}
// isCloudWatchQuery checks if a query target is a CloudWatch query.
func isCloudWatchQuery(target map[string]interface{}) bool {
// Check for required CloudWatch query fields
_, hasDimensions := target["dimensions"]
_, hasNamespace := target["namespace"]
_, hasRegion := target["region"]
_, hasMetricName := target["metricName"]
return hasDimensions && hasNamespace && hasRegion && hasMetricName
}
// isLegacyCloudWatchAnnotationQuery checks if an annotation is a legacy CloudWatch annotation query.
func isLegacyCloudWatchAnnotationQuery(annotation map[string]interface{}) bool {
// Check for required CloudWatch annotation fields
_, hasDimensions := annotation["dimensions"]
_, hasNamespace := annotation["namespace"]
_, hasRegion := annotation["region"]
_, hasPrefixMatching := annotation["prefixMatching"]
_, hasStatistics := annotation["statistics"]
return hasDimensions && hasNamespace && hasRegion && hasPrefixMatching && hasStatistics
}
// generateNextRefId generates a new refId for additional queries created during migration.
func generateNextRefId(allTargets []interface{}, additionalIndex int) string {
// Collect all existing refIds
used := make(map[string]bool)
for _, target := range allTargets {
if t, ok := target.(map[string]interface{}); ok {
if refId, ok := t["refId"].(string); ok && refId != "" {
used[refId] = true
}
}
}
// Generate next available refId starting from A
for c := 'A'; c <= 'Z'; c++ {
candidate := string(c)
if !used[candidate] {
return candidate
}
}
// If all single letters are taken, use double letters
for c1 := 'A'; c1 <= 'Z'; c1++ {
for c2 := 'A'; c2 <= 'Z'; c2++ {
candidate := string(c1) + string(c2)
if !used[candidate] {
return candidate
}
}
}
// Fallback (should never happen in practice)
return "X" + string(rune('0'+additionalIndex))
}