diff --git a/apps/dashvalidator/pkg/validator/dashboard.go b/apps/dashvalidator/pkg/validator/dashboard.go index 10527328304..677ab4b2733 100644 --- a/apps/dashvalidator/pkg/validator/dashboard.go +++ b/apps/dashvalidator/pkg/validator/dashboard.go @@ -204,8 +204,26 @@ func extractQueriesFromDashboard(dashboardJSON map[string]interface{}) ([]Dashbo // isV1Dashboard checks if a dashboard is in v1 (legacy) format // v1 dashboards have a "panels" array at the top level +// v2 dashboards have "elements" map and "layout" structure +// +// This follows Grafana's official dashboard conversion logic which uses +// type-safe assertions to distinguish between formats. +// Reference: apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go:450 func isV1Dashboard(dashboard map[string]interface{}) bool { - _, hasPanels := dashboard["panels"] + // Check for v2 indicators first (positive identification) + // v2 dashboards use a map of elements, not an array + if _, hasElements := dashboard["elements"].(map[string]interface{}); hasElements { + return false // Definitely v2 + } + + // v2 dashboards also have a layout structure + if _, hasLayout := dashboard["layout"]; hasLayout { + return false // v2 has layout field + } + + // Check for v1 panels with type assertion (must be an array) + // This is type-safe: `{"panels": "string"}` would fail this check and return false + _, hasPanels := dashboard["panels"].([]interface{}) return hasPanels } diff --git a/apps/dashvalidator/pkg/validator/dashboard_test.go b/apps/dashvalidator/pkg/validator/dashboard_test.go new file mode 100644 index 00000000000..dffc47d2c40 --- /dev/null +++ b/apps/dashvalidator/pkg/validator/dashboard_test.go @@ -0,0 +1,197 @@ +package validator + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsV1Dashboard(t *testing.T) { + tests := []struct { + name string + dashboard map[string]interface{} + expected bool + }{ + { + name: "v1 dashboard with panels array", + dashboard: map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "title": "Panel 1", + "type": "timeseries", + }, + }, + }, + expected: true, + }, + { + name: "v1 dashboard with empty panels", + dashboard: map[string]interface{}{ + "panels": []interface{}{}, + }, + expected: true, + }, + { + name: "v2 dashboard with elements map", + dashboard: map[string]interface{}{ + "elements": map[string]interface{}{ + "panel-1": map[string]interface{}{ + "kind": "Panel", + "spec": map[string]interface{}{ + "id": 1, + "title": "Panel 1", + }, + }, + }, + }, + expected: false, + }, + { + name: "v2 dashboard with layout", + dashboard: map[string]interface{}{ + "layout": map[string]interface{}{ + "kind": "GridLayout", + "spec": map[string]interface{}{ + "items": []interface{}{}, + }, + }, + }, + expected: false, + }, + { + name: "v2 dashboard with both elements and layout", + dashboard: map[string]interface{}{ + "elements": map[string]interface{}{ + "panel-1": map[string]interface{}{ + "kind": "Panel", + }, + }, + "layout": map[string]interface{}{ + "kind": "GridLayout", + }, + }, + expected: false, + }, + { + name: "empty dashboard", + dashboard: map[string]interface{}{}, + expected: false, + }, + { + name: "dashboard with wrong panels type (string instead of array)", + dashboard: map[string]interface{}{ + "panels": "this-should-be-array-not-string", + }, + expected: false, + }, + { + name: "dashboard with other fields only", + dashboard: map[string]interface{}{ + "title": "Test Dashboard", + "uid": "test-uid", + "tags": []string{"monitoring"}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isV1Dashboard(tt.dashboard) + require.Equal(t, tt.expected, result, "isV1Dashboard() returned unexpected result") + }) + } +} + +func TestExtractQueriesFromDashboard_VersionValidation(t *testing.T) { + tests := []struct { + name string + dashboard map[string]interface{} + expectError bool + errorContains string + }{ + { + name: "valid v1 dashboard extracts queries successfully", + dashboard: map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "title": "CPU Usage", + "type": "timeseries", + "gridPos": map[string]interface{}{ + "h": 8, + "w": 12, + "x": 0, + "y": 0, + }, + "targets": []interface{}{ + map[string]interface{}{ + "datasource": map[string]interface{}{ + "type": "prometheus", + "uid": "test-prometheus", + }, + "expr": "rate(cpu_usage_total[5m])", + "refId": "A", + }, + }, + }, + }, + }, + expectError: false, + }, + { + name: "v2 dashboard returns unsupported format error", + dashboard: map[string]interface{}{ + "elements": map[string]interface{}{ + "panel-1": map[string]interface{}{ + "kind": "Panel", + "spec": map[string]interface{}{ + "id": 1, + "title": "Panel 1", + "data": map[string]interface{}{ + "kind": "QueryGroup", + }, + "vizConfig": map[string]interface{}{ + "kind": "TimeSeriesVisualConfig", + "pluginId": "timeseries", + }, + }, + }, + }, + "layout": map[string]interface{}{ + "kind": "GridLayout", + "spec": map[string]interface{}{ + "items": []interface{}{}, + }, + }, + }, + expectError: true, + errorContains: "unsupported dashboard format", + }, + { + name: "invalid dashboard (no panels or elements) returns error", + dashboard: map[string]interface{}{ + "title": "Invalid Dashboard", + "description": "This dashboard has no panels or elements", + "tags": []string{"test"}, + }, + expectError: true, + errorContains: "unsupported dashboard format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + queries, err := extractQueriesFromDashboard(tt.dashboard) + + if tt.expectError { + require.Error(t, err, "Expected error but got none") + require.Contains(t, err.Error(), tt.errorContains, "Error message doesn't contain expected substring") + } else { + require.NoError(t, err, "Expected no error but got: %v", err) + require.NotNil(t, queries, "Queries should not be nil for valid dashboard") + } + }) + } +}