diff --git a/apps/dashvalidator/pkg/app/app.go b/apps/dashvalidator/pkg/app/app.go index 72a544f2f75..ace40cf8b44 100644 --- a/apps/dashvalidator/pkg/app/app.go +++ b/apps/dashvalidator/pkg/app/app.go @@ -135,6 +135,16 @@ func handleCheckRoute( }) } + // MVP: Only support single datasource validation + if len(req.DatasourceMappings) != 1 { + logger.Error("MVP only supports single datasource validation", "numDatasources", len(req.DatasourceMappings)) + w.WriteHeader(http.StatusBadRequest) + return json.NewEncoder(w).Encode(map[string]string{ + "error": fmt.Sprintf("MVP only supports single datasource validation, got %d datasources", len(req.DatasourceMappings)), + "code": "invalid_request", + }) + } + // Step 2: Build validator request validatorReq := validator.DashboardCompatibilityRequest{ DashboardJSON: req.DashboardJSON, diff --git a/apps/dashvalidator/pkg/validator/dashboard.go b/apps/dashvalidator/pkg/validator/dashboard.go index 1d4415f5adc..10527328304 100644 --- a/apps/dashvalidator/pkg/validator/dashboard.go +++ b/apps/dashvalidator/pkg/validator/dashboard.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strings" ) // DashboardCompatibilityRequest contains the dashboard and datasource mappings to validate @@ -44,6 +45,13 @@ type DatasourceValidationResult struct { // ValidateDashboardCompatibility is the main entry point for validating dashboard compatibility // It extracts queries from the dashboard, validates them against each datasource, and returns aggregated results func ValidateDashboardCompatibility(ctx context.Context, req DashboardCompatibilityRequest) (*DashboardCompatibilityResult, error) { + // MVP: Only support single datasource validation + if len(req.DatasourceMappings) != 1 { + return nil, fmt.Errorf("MVP only supports single datasource validation, got %d datasources", len(req.DatasourceMappings)) + } + + singleDatasource := req.DatasourceMappings[0] + result := &DashboardCompatibilityResult{ DatasourceResults: make([]DatasourceValidationResult, 0, len(req.DatasourceMappings)), } @@ -59,8 +67,8 @@ func ValidateDashboardCompatibility(ctx context.Context, req DashboardCompatibil fmt.Printf("[DEBUG] Query %d: DS=%s, RefID=%s, Query=%s\n", i, q.DatasourceUID, q.RefID, q.QueryText) } - // Step 2: Group queries by datasource UID - queriesByDatasource := groupQueriesByDatasource(queries) + // Step 2: Group queries by datasource UID (with variable resolution for MVP) + queriesByDatasource := groupQueriesByDatasource(queries, singleDatasource.UID, req.DashboardJSON) fmt.Printf("[DEBUG] Grouped queries by %d datasources\n", len(queriesByDatasource)) for dsUID, dsQueries := range queriesByDatasource { @@ -287,6 +295,186 @@ func getDatasourceUIDFromValue(ds interface{}) string { } } +// isVariableReference checks if a string is a template variable reference +// Matches patterns: ${varname}, $varname, [[varname]] +// Follows Grafana's frontend regex: /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g +// where \w = [A-Za-z0-9_] (alphanumeric + underscore, NO dashes) +func isVariableReference(uid string) bool { + if uid == "" { + return false + } + + // Match ${...} pattern - requires at least one \w character inside braces + if len(uid) > 3 && uid[0] == '$' && uid[1] == '{' && uid[len(uid)-1] == '}' { + // Extract content between ${ and } + content := uid[2 : len(uid)-1] + if len(content) == 0 { + return false // Empty braces ${} not allowed + } + // Check if content starts with \w+ (before any . or :) + for i, ch := range content { + if ch == '.' || ch == ':' { + // Found delimiter, check if we had at least one \w before it + return i > 0 + } + // Must be alphanumeric or underscore + if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || ch == '_') { + return false + } + } + return true // All characters were valid \w + } + + // Match $varname pattern - requires at least one \w character after $ + // \w = alphanumeric + underscore (digits ARE allowed, dashes are NOT) + if uid[0] == '$' && len(uid) > 1 { + for i := 1; i < len(uid); i++ { + ch := uid[i] + // \w = [A-Za-z0-9_] only (NO dashes) + if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || ch == '_') { + return false + } + } + return true + } + + // Match [[varname]] pattern - requires at least one \w character inside brackets + // Also supports [[varname:format]] syntax + if len(uid) > 4 && uid[0] == '[' && uid[1] == '[' && + uid[len(uid)-2] == ']' && uid[len(uid)-1] == ']' { + // Extract content between [[ and ]] + content := uid[2 : len(uid)-2] + if len(content) == 0 { + return false // Empty brackets [[]] not allowed + } + // Check if content starts with \w+ (before any :) + for i, ch := range content { + if ch == ':' { + // Found format delimiter, check if we had at least one \w before it + return i > 0 + } + // Must be alphanumeric or underscore + if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || ch == '_') { + return false + } + } + return true // All characters were valid \w + } + + return false +} + +// extractVariableName extracts the variable name from a variable reference +// Returns only the name part, excluding fieldPath (after .) and format (after :) +// Examples: ${var.field} -> "var", [[var:text]] -> "var", $datasource -> "datasource" +func extractVariableName(varRef string) string { + if !isVariableReference(varRef) { + return "" + } + + // Handle ${varname} pattern - may include .fieldPath or :format + if len(varRef) > 3 && varRef[0] == '$' && varRef[1] == '{' && varRef[len(varRef)-1] == '}' { + content := varRef[2 : len(varRef)-1] + // Extract only up to . or : + for i, ch := range content { + if ch == '.' || ch == ':' { + return content[:i] + } + } + return content + } + + // Handle $varname pattern - no modifiers possible + if varRef[0] == '$' && len(varRef) > 1 { + return varRef[1:] + } + + // Handle [[varname]] pattern - may include :format + if len(varRef) > 4 && varRef[0] == '[' && varRef[1] == '[' { + content := varRef[2 : len(varRef)-2] + // Extract only up to : + for i, ch := range content { + if ch == ':' { + return content[:i] + } + } + return content + } + + return "" +} + +// isPrometheusVariable checks if a variable reference points to a Prometheus datasource +// Looks in dashboard.__inputs for the datasource type +func isPrometheusVariable(varRef string, dashboardJSON map[string]interface{}) bool { + if !isVariableReference(varRef) { + return false + } + + varName := extractVariableName(varRef) + if varName == "" { + return false + } + + // Look for __inputs array in dashboard + inputs, hasInputs := dashboardJSON["__inputs"].([]interface{}) + if !hasInputs { + // No __inputs, assume it might be Prometheus (MVP: single datasource) + // This is a fallback for dashboards without explicit __inputs + return true + } + + // Search for this variable in __inputs + for _, inputInterface := range inputs { + input, ok := inputInterface.(map[string]interface{}) + if !ok { + continue + } + + // Check if this input matches our variable name + inputName := getStringValue(input, "name", "") + inputType := getStringValue(input, "type", "") + inputPluginID := getStringValue(input, "pluginId", "") + + // Match by name (case-insensitive for flexibility) + if inputName != "" && varName != "" { + if inputName == varName || + strings.EqualFold(inputName, varName) || + strings.Contains(strings.ToLower(varName), strings.ToLower(inputName)) { + // Check if it's a datasource input with prometheus plugin + if inputType == "datasource" && inputPluginID == "prometheus" { + return true + } + } + } + } + + // Not found or not Prometheus + return false +} + +// resolveDatasourceUID resolves a datasource UID, handling variable references (MVP: single datasource) +// For MVP, all Prometheus variables resolve to the single datasource UID +func resolveDatasourceUID(uid string, singleDatasourceUID string, dashboardJSON map[string]interface{}) string { + // If not a variable, return as-is (concrete UID) + if !isVariableReference(uid) { + return uid + } + + // Check if it's a Prometheus variable + if isPrometheusVariable(uid, dashboardJSON) { + fmt.Printf("[DEBUG] Resolved Prometheus variable %s to %s\n", uid, singleDatasourceUID) + return singleDatasourceUID + } + + // Non-Prometheus variable, return as-is (will be ignored in grouping) + fmt.Printf("[DEBUG] Variable %s is not a Prometheus variable, skipping\n", uid) + return uid +} + // extractQueryText extracts the query text from a target // Different datasources use different field names (expr, query, rawSql, etc.) func extractQueryText(target map[string]interface{}) string { @@ -337,7 +525,8 @@ type DashboardQuery struct { } // groupQueriesByDatasource groups dashboard queries by their datasource UID -func groupQueriesByDatasource(queries []DashboardQuery) map[string][]Query { +// For MVP: resolves Prometheus template variables to the single datasource UID +func groupQueriesByDatasource(queries []DashboardQuery, singleDatasourceUID string, dashboardJSON map[string]interface{}) map[string][]Query { grouped := make(map[string][]Query) for _, dq := range queries { @@ -348,7 +537,13 @@ func groupQueriesByDatasource(queries []DashboardQuery) map[string][]Query { PanelID: dq.PanelID, } - grouped[dq.DatasourceUID] = append(grouped[dq.DatasourceUID], q) + // Resolve datasource UID (handles both concrete UIDs and variables) + resolvedUID := resolveDatasourceUID(dq.DatasourceUID, singleDatasourceUID, dashboardJSON) + + // Only add to grouping if we got a valid resolved UID + if resolvedUID != "" { + grouped[resolvedUID] = append(grouped[resolvedUID], q) + } } return grouped diff --git a/apps/dashvalidator/pkg/validator/variables_test.go b/apps/dashvalidator/pkg/validator/variables_test.go new file mode 100644 index 00000000000..e6b2dc9bd6d --- /dev/null +++ b/apps/dashvalidator/pkg/validator/variables_test.go @@ -0,0 +1,171 @@ +package validator + +import ( + "testing" +) + +func TestIsVariableReference(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"dollar brace", "${prometheus}", true}, + {"dollar simple", "$datasource", true}, + {"double bracket", "[[prometheus]]", true}, + {"concrete uid", "abcd1234", false}, + {"empty string", "", false}, + {"dollar only", "$", false}, + {"empty braces", "${}", false}, + {"number start", "$123", true}, // Changed: Grafana ACCEPTS digits (per \w+ regex) + {"all digits", "$999", true}, // New: All digits are valid per \w+ + {"special chars dash", "$ds-name", false}, // Changed: Grafana REJECTS dashes (not in \w) + {"underscore", "$DS_PROMETHEUS", true}, + {"complex variable", "${DS_PROMETHEUS}", true}, + {"simple letter", "$p", true}, + {"with fieldpath", "${var.field}", true}, // New: Test fieldPath syntax + {"with format", "[[var:text]]", true}, // New: Test format syntax + {"brace with format", "${var:json}", true}, // New: Test brace format syntax + {"digit in brackets", "[[123]]", true}, // New: Digits allowed in all patterns + {"empty brackets", "[[]]", false}, // New: Empty brackets rejected + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isVariableReference(tt.input) + if result != tt.expected { + t.Errorf("isVariableReference(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestExtractVariableName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"dollar brace", "${prometheus}", "prometheus"}, + {"dollar simple", "$datasource", "datasource"}, + {"double bracket", "[[prometheus]]", "prometheus"}, + {"not variable", "concrete-uid", ""}, + {"empty", "", ""}, + {"complex name", "${DS_PROMETHEUS}", "DS_PROMETHEUS"}, + {"with underscore", "$DS_NAME", "DS_NAME"}, + {"digit variable", "$123", "123"}, // New: Digits are valid + {"with fieldpath", "${var.field}", "var"}, // Changed: Extract only name, not fieldPath + {"with format brace", "${var:json}", "var"}, // Changed: Extract only name, not format + {"with format bracket", "[[var:text]]", "var"}, // Changed: Extract only name, not format + {"fieldpath and format", "${var.field:json}", "var"}, // New: Extract only name from complex syntax + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractVariableName(tt.input) + if result != tt.expected { + t.Errorf("extractVariableName(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestIsPrometheusVariable(t *testing.T) { + // Dashboard with Prometheus __inputs + dashboardWithPrometheus := map[string]interface{}{ + "__inputs": []interface{}{ + map[string]interface{}{ + "name": "DS_PROMETHEUS", + "type": "datasource", + "pluginId": "prometheus", + }, + }, + } + + // Dashboard with MySQL __inputs + dashboardWithMySQL := map[string]interface{}{ + "__inputs": []interface{}{ + map[string]interface{}{ + "name": "DS_MYSQL", + "type": "datasource", + "pluginId": "mysql", + }, + }, + } + + // Dashboard without __inputs + dashboardWithoutInputs := map[string]interface{}{ + "title": "Test Dashboard", + } + + tests := []struct { + name string + varRef string + dashboard map[string]interface{} + expected bool + }{ + {"prometheus variable with inputs", "${DS_PROMETHEUS}", dashboardWithPrometheus, true}, + {"prometheus simple var", "$DS_PROMETHEUS", dashboardWithPrometheus, true}, + {"mysql variable", "${DS_MYSQL}", dashboardWithMySQL, false}, + {"not variable", "concrete-uid", dashboardWithPrometheus, false}, + {"variable without inputs", "${prometheus}", dashboardWithoutInputs, true}, // Fallback to true for MVP + {"wrong variable name", "${OTHER}", dashboardWithPrometheus, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isPrometheusVariable(tt.varRef, tt.dashboard) + if result != tt.expected { + t.Errorf("isPrometheusVariable(%q, dashboard) = %v, want %v", tt.varRef, result, tt.expected) + } + }) + } +} + +func TestResolveDatasourceUID(t *testing.T) { + singleUID := "prom-uid-123" + + dashboardWithPrometheus := map[string]interface{}{ + "__inputs": []interface{}{ + map[string]interface{}{ + "name": "DS_PROMETHEUS", + "type": "datasource", + "pluginId": "prometheus", + }, + }, + } + + dashboardWithMySQL := map[string]interface{}{ + "__inputs": []interface{}{ + map[string]interface{}{ + "name": "DS_MYSQL", + "type": "datasource", + "pluginId": "mysql", + }, + }, + } + + tests := []struct { + name string + uid string + dashboard map[string]interface{} + expectedUID string + description string + }{ + {"concrete uid", "concrete-123", dashboardWithPrometheus, "concrete-123", "should return concrete UID as-is"}, + {"prometheus variable", "${DS_PROMETHEUS}", dashboardWithPrometheus, singleUID, "should resolve to single datasource UID"}, + {"prometheus simple var", "$DS_PROMETHEUS", dashboardWithPrometheus, singleUID, "should resolve simple $ syntax"}, + {"mysql variable", "${DS_MYSQL}", dashboardWithMySQL, "${DS_MYSQL}", "should return non-Prometheus variable as-is"}, + {"empty uid", "", dashboardWithPrometheus, "", "should return empty string as-is"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := resolveDatasourceUID(tt.uid, singleUID, tt.dashboard) + if result != tt.expectedUID { + t.Errorf("resolveDatasourceUID(%q, %q, dashboard) = %q, want %q (%s)", + tt.uid, singleUID, result, tt.expectedUID, tt.description) + } + }) + } +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CompatibilityModal.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CompatibilityModal.tsx index c45198dafb5..e0bd0d50701 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/CompatibilityModal.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CompatibilityModal.tsx @@ -123,11 +123,13 @@ export const CompatibilityModal = ({ isOpen, onDismiss, dashboardJson, datasourc Compatibility Score - - {result.compatibilityScore}% + + {result.compatibilityScore}% - {JSON.stringify(result)} + + {JSON.stringify(result, null, 2)} + {/* Feature #12: CompatibilityScoreDisplay with color coding */}
{result.compatibilityScore}%
{JSON.stringify(result, null, 2)}