Handle datasource variables in validator query grouping

This commit is contained in:
alexandra vargas
2026-01-08 14:20:47 +01:00
parent 74548dbb73
commit 06d11d739b
4 changed files with 385 additions and 7 deletions
+10
View File
@@ -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,
+199 -4
View File
@@ -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
@@ -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)
}
})
}
}
@@ -123,11 +123,13 @@ export const CompatibilityModal = ({ isOpen, onDismiss, dashboardJson, datasourc
<Text element="h3">
<Trans i18nKey="compatibility-modal.score-title">Compatibility Score</Trans>
</Text>
<Text element="p" variant="h2">
{result.compatibilityScore}%
<Text element="p">
<pre>{result.compatibilityScore}%</pre>
</Text>
<Text element="p">{JSON.stringify(result)}</Text>
<Text element="p">
<pre>{JSON.stringify(result, null, 2)}</pre>
</Text>
</div>
{/* Feature #12: CompatibilityScoreDisplay with color coding */}