SuggestedDashboards: dashvalidator app - Add Prometheus Support (#115769)

Dashboard Validator App - Prometheus support

Validates dashboard compatibility with Prometheus datasources. The app analyzes dashboard queries against                             available metrics to produce a compatibility score.                                              

Backend (Go):
- App scaffolding with grafana-app-sdk, custom `/check` endpoint
- Prometheus validator: parses PromQL queries, fetches available metrics, and computes per-query and per-datasource compatibility
- Metric caching, input validation, request timeouts, and structured error handling (not found, unreachable, auth, timeout)
- Custom authorizer with role-based access control
- Datasource-scoped validation and variable interpolation support

Frontend (React/TypeScript):
- API client for the validator backend
- Compatibility badge inline on community dashboards
- Detail modal showing per-datasource and per-query results
- Gated behind dashboardValidatorApp feature flag

Tests:
- Go unit tests for parser, fetcher, validator, and JSON serialization
- Dashboard query extraction tests
This commit is contained in:
Alexa Vargas
2026-02-16 20:06:35 +01:00
committed by GitHub
parent 2905aa0b63
commit 808cf7e4f1
41 changed files with 8733 additions and 97 deletions
+107 -15
View File
@@ -3,22 +3,41 @@ module github.com/grafana/grafana/apps/dashvalidator
go 1.25.7
require (
github.com/grafana/authlib/types v0.0.0-20260203131350-b83e80394acc
github.com/grafana/grafana v0.0.0-00010101000000-000000000000
github.com/grafana/grafana-app-sdk v0.50.4
github.com/grafana/grafana-app-sdk/logging v0.50.2
github.com/grafana/grafana/pkg/apimachinery v0.0.0
github.com/prometheus/prometheus v0.303.1
github.com/stretchr/testify v1.11.1
k8s.io/apimachinery v0.35.1
k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4
)
require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cuelang.org/go v0.11.1 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.12.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Machiel/slugify v1.0.1 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/apache/arrow-go/v18 v18.5.1 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/at-wat/mqtt-go v0.19.6 // indirect
github.com/aws/aws-sdk-go v1.55.7 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
@@ -28,32 +47,53 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/bluele/gcache v0.0.2 // indirect
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cheekybits/genny v1.0.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cockroachdb/apd/v3 v3.2.1 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dennwc/varint v1.0.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect
github.com/dlmiddlecote/sqlstats v1.0.2 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2 // indirect
github.com/dolthub/go-icu-regex v0.0.0-20250916051405-78a38d478790 // indirect
github.com/dolthub/go-mysql-server v0.19.1-0.20250410182021-5632d67cd46e // indirect
github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71 // indirect
github.com/dolthub/vitess v0.0.0-20250930230441-70c2c6a98e33 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gchaincl/sqlhooks v1.3.0 // indirect
github.com/getkin/kin-openapi v0.133.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-kit/log v0.2.1 // indirect
github.com/go-ldap/ldap/v3 v3.4.4 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.24.1 // indirect
github.com/go-openapi/errors v0.22.4 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/loads v0.23.2 // indirect
github.com/go-openapi/runtime v0.28.0 // indirect
github.com/go-openapi/spec v0.22.3 // indirect
github.com/go-openapi/strfmt v0.25.0 // indirect
github.com/go-openapi/swag v0.25.4 // indirect
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
@@ -66,12 +106,16 @@ require (
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-openapi/validate v0.25.1 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/googleapis v1.4.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang-migrate/migrate/v4 v4.7.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
@@ -80,20 +124,22 @@ require (
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/google/wire v0.7.0 // indirect
github.com/grafana/alerting v0.0.0-20260206150146-b5f69c55f91a // indirect
github.com/grafana/authlib v0.0.0-20260203153107-16a114a99f67 // indirect
github.com/grafana/authlib/types v0.0.0-20260203131350-b83e80394acc // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
github.com/grafana/dskit v0.0.0-20260108123158-1a1acfb6ef2e // indirect
github.com/grafana/grafana-aws-sdk v1.4.3 // indirect
github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect
github.com/grafana/grafana-plugin-sdk-go v0.287.0 // indirect
github.com/grafana/grafana/pkg/apimachinery v0.0.0 // indirect
github.com/grafana/grafana/apps/dashboard v0.0.0 // indirect
github.com/grafana/grafana/apps/provisioning v0.0.0 // indirect
github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect
github.com/grafana/grafana/pkg/plugins v0.0.0 // indirect
github.com/grafana/grafana/pkg/semconv v0.0.0 // indirect
github.com/grafana/otel-profiling-go v0.5.1 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
github.com/grafana/sqlds/v5 v5.0.4 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect
@@ -110,8 +156,11 @@ require (
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/memberlist v0.5.3 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/jaegertracing/jaeger-idl v0.6.0 // indirect
github.com/jessevdk/go-flags v1.6.1 // indirect
github.com/jmespath-community/go-jmespath v1.1.1 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
@@ -119,6 +168,8 @@ require (
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lestrrat-go/strftime v1.0.4 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattetti/filebuffer v1.0.1 // indirect
@@ -126,10 +177,14 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
github.com/mdlayher/vsock v1.2.1 // indirect
github.com/miekg/dns v1.1.69 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/mithrandie/csvq v1.18.1 // indirect
github.com/mithrandie/csvq-driver v1.7.0 // indirect
github.com/mithrandie/go-file/v2 v2.1.0 // indirect
@@ -151,31 +206,42 @@ require (
github.com/open-feature/go-sdk-contrib/providers/go-feature-flag v0.2.6 // indirect
github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.7 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pierrec/lz4/v4 v4.1.23 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/alertmanager v0.28.2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/common/sigv4 v0.1.0 // indirect
github.com/prometheus/exporter-toolkit v0.15.1 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect
github.com/redis/go-redis/v9 v9.14.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect
github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/thomaspoignant/go-feature-flag v1.42.0 // indirect
github.com/tjhop/slog-gokit v0.1.5 // indirect
github.com/woodsbury/decimal128 v1.4.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.64.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/contrib/propagators/jaeger v1.39.0 // indirect
go.opentelemetry.io/contrib/samplers/jaegerremote v0.33.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
@@ -203,14 +269,20 @@ require (
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/tools/godoc v0.1.0-deprecated // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
gonum.org/v1/gonum v0.17.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/mail.v2 v2.3.1 // indirect
gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect
gopkg.in/telebot.v3 v3.3.8 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.35.1 // indirect
@@ -233,18 +305,38 @@ require (
// transitive dependencies that need replaced
// TODO: stop depending on grafana core
replace github.com/grafana/grafana => ../..
replace (
github.com/grafana/grafana => ../..
replace github.com/grafana/grafana/pkg/apimachinery => ../../pkg/apimachinery
github.com/grafana/grafana/apps/advisor => ../advisor
github.com/grafana/grafana/apps/alerting/alertenrichment => ../alerting/alertenrichment
github.com/grafana/grafana/apps/alerting/historian => ../alerting/historian
github.com/grafana/grafana/apps/alerting/notifications => ../alerting/notifications
github.com/grafana/grafana/apps/alerting/rules => ../alerting/rules
github.com/grafana/grafana/apps/annotation => ../annotation
github.com/grafana/grafana/apps/collections => ../collections
github.com/grafana/grafana/apps/correlations => ../correlations
github.com/grafana/grafana/apps/dashboard => ../dashboard
github.com/grafana/grafana/apps/example => ../example
github.com/grafana/grafana/apps/folder => ../folder
github.com/grafana/grafana/apps/iam => ../iam
github.com/grafana/grafana/apps/live => ../live
github.com/grafana/grafana/apps/logsdrilldown => ../logsdrilldown
github.com/grafana/grafana/apps/playlist => ../playlist
github.com/grafana/grafana/apps/plugins => ../plugins
github.com/grafana/grafana/apps/preferences => ../preferences
github.com/grafana/grafana/apps/provisioning => ../provisioning
github.com/grafana/grafana/apps/quotas => ../quotas
github.com/grafana/grafana/apps/scope => ../scope
github.com/grafana/grafana/apps/secret => ../secret
github.com/grafana/grafana/apps/shorturl => ../shorturl
replace github.com/grafana/grafana/pkg/apiserver => ../../pkg/apiserver
github.com/grafana/grafana/pkg/aggregator => ../../pkg/aggregator
github.com/grafana/grafana/pkg/apimachinery => ../../pkg/apimachinery
github.com/grafana/grafana/pkg/apiserver => ../../pkg/apiserver
github.com/grafana/grafana/pkg/plugins => ../../pkg/plugins
github.com/grafana/grafana/pkg/semconv => ../../pkg/semconv
github.com/grafana/grafana/pkg/storage/unified/resource/kv => ../../pkg/storage/unified/resource/kv
replace github.com/grafana/grafana/apps/dashboard => ../dashboard
replace github.com/grafana/grafana/apps/provisioning => ../provisioning
replace github.com/grafana/grafana/pkg/semconv => ../../pkg/semconv
replace github.com/grafana/grafana/pkg/plugins => ../../pkg/plugins
replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604
github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604
)
File diff suppressed because it is too large Load Diff
@@ -154,4 +154,8 @@ dashboardcompatibilityscorev0alpha1: {
// Calculated as: (foundMetrics / totalMetrics) * 100
// 100 = query will work perfectly, 0 = query will return no data.
compatibilityScore: float64
// Optional error message for queries that failed to parse.
// When present, the query is treated as 0% compatible.
parseError?: string
}
+1 -1
View File
@@ -35,7 +35,7 @@ manifest: {
// It includes kinds which the v1alpha1 API serves, and (future) custom routes served globally from the v1alpha1 version.
v1alpha1: {
// kinds is the list of kinds served by this version
kinds: [dashboardcompatibilityscorev0alpha1]
kinds: []
// [OPTIONAL]
// served indicates whether this particular version is served by the API server.
// served should be set to false before a version is removed from the manifest entirely.
@@ -84,6 +84,9 @@ type DashboardCompatibilityScoreQueryBreakdown struct {
// Calculated as: (foundMetrics / totalMetrics) * 100
// 100 = query will work perfectly, 0 = query will return no data.
CompatibilityScore float64 `json:"compatibilityScore"`
// Optional error message for queries that failed to parse.
// When present, the query is treated as 0% compatible.
ParseError *string `json:"parseError,omitempty"`
}
// NewDashboardCompatibilityScoreQueryBreakdown creates a new DashboardCompatibilityScoreQueryBreakdown object.
File diff suppressed because one or more lines are too long
+365 -18
View File
@@ -3,23 +3,79 @@ package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana-app-sdk/resource"
"github.com/grafana/grafana-app-sdk/simple"
"k8s.io/apimachinery/pkg/runtime/schema"
validatorv1alpha1 "github.com/grafana/grafana/apps/dashvalidator/pkg/apis/dashvalidator/v1alpha1"
"github.com/grafana/grafana/apps/dashvalidator/pkg/cache"
"github.com/grafana/grafana/apps/dashvalidator/pkg/validator"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
"github.com/grafana/grafana/pkg/util"
)
type DashValidatorConfig struct {
DatasourceSvc datasources.DataSourceService
PluginCtx *plugincontext.Provider
DatasourceSvc datasources.DataSourceService
HTTPClientProvider httpclient.Provider
MetricsCache *cache.MetricsCache // Injected by register.go
Validators map[string]validator.DatasourceValidator // Injected by register.go, keyed by datasource type
AC accesscontrol.AccessControl // For per-datasource scoped permission checks
}
// checkRequest matches the CUE schema for POST /check request
type checkRequest struct {
DashboardJSON map[string]interface{} `json:"dashboardJson"`
DatasourceMappings []datasourceMapping `json:"datasourceMappings"`
}
// datasourceMapping represents a datasource to validate against
type datasourceMapping struct {
UID string `json:"uid"`
Type string `json:"type"`
Name *string `json:"name,omitempty"`
}
// checkResponse matches the CUE schema for POST /check response
type checkResponse struct {
CompatibilityScore float64 `json:"compatibilityScore"`
DatasourceResults []datasourceResult `json:"datasourceResults"`
}
// datasourceResult contains validation results for a single datasource
type datasourceResult struct {
UID string `json:"uid"`
Type string `json:"type"`
Name *string `json:"name,omitempty"`
TotalQueries int `json:"totalQueries"`
CheckedQueries int `json:"checkedQueries"`
TotalMetrics int `json:"totalMetrics"`
FoundMetrics int `json:"foundMetrics"`
MissingMetrics []string `json:"missingMetrics"`
QueryBreakdown []queryResult `json:"queryBreakdown"`
CompatibilityScore float64 `json:"compatibilityScore"`
}
// queryResult contains validation results for a single query
type queryResult struct {
PanelTitle string `json:"panelTitle"`
PanelID int `json:"panelID"`
QueryRefID string `json:"queryRefId"`
TotalMetrics int `json:"totalMetrics"`
FoundMetrics int `json:"foundMetrics"`
MissingMetrics []string `json:"missingMetrics"`
CompatibilityScore float64 `json:"compatibilityScore"`
ParseError *string `json:"parseError,omitempty"`
}
func New(cfg app.Config) (app.App, error) {
@@ -30,6 +86,12 @@ func New(cfg app.Config) (app.App, error) {
log := logging.DefaultLogger.With("app", "dashvalidator")
// MetricsCache and Validators are created by register.go and passed via config
metricsCache := specificConfig.MetricsCache
validators := specificConfig.Validators
log.Info("Initialized dashvalidator app", "numValidators", len(validators))
// configure our app
simpleConfig := simple.AppConfig{
Name: "dashvalidator",
@@ -42,7 +104,7 @@ func New(cfg app.Config) (app.App, error) {
Namespaced: true,
Path: "check",
Method: "POST",
}: handleCheckRoute(log, specificConfig.DatasourceSvc, specificConfig.PluginCtx),
}: handleCheckRoute(log, specificConfig.DatasourceSvc, specificConfig.HTTPClientProvider, validators, specificConfig.AC),
},
},
}
@@ -52,6 +114,9 @@ func New(cfg app.Config) (app.App, error) {
return nil, fmt.Errorf("failed to create app: %w", err)
}
// Register MetricsCache as a runnable so its cleanup goroutine is managed by the app lifecycle
a.AddRunnable(metricsCache)
return a, nil
}
@@ -59,30 +124,312 @@ func New(cfg app.Config) (app.App, error) {
func handleCheckRoute(
log logging.Logger,
datasourceSvc datasources.DataSourceService,
pluginCtx *plugincontext.Provider,
httpClientProvider httpclient.Provider,
validators map[string]validator.DatasourceValidator,
ac accesscontrol.AccessControl,
) func(context.Context, app.CustomRouteResponseWriter, *app.CustomRouteRequest) error {
return func(ctx context.Context, w app.CustomRouteResponseWriter, r *app.CustomRouteRequest) error {
// Set a timeout for the entire request processing
// This prevents the handler from hanging indefinitely on slow external services
const requestTimeout = 30 * time.Second
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
defer cancel()
logger := log.WithContext(ctx)
logger.Info("Received compatibility check request")
// TODO validation logic here
// Step 1: Parse request body
body, err := io.ReadAll(r.Body)
if err != nil {
logger.Error("Failed to read request body", "error", err)
w.WriteHeader(http.StatusBadRequest)
return json.NewEncoder(w).Encode(map[string]string{
"error": "failed to read request body",
})
}
// for now a simple response
var req checkRequest
if err := json.Unmarshal(body, &req); err != nil {
logger.Error("Failed to parse request JSON", "error", err)
w.WriteHeader(http.StatusBadRequest)
return json.NewEncoder(w).Encode(map[string]string{
"error": "invalid JSON in request body",
})
}
// 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",
})
}
// Validate datasource mapping fields
for i, dsMapping := range req.DatasourceMappings {
// Validate UID using Grafana's standard validation
// Checks: not empty, max 40 chars, valid characters (a-zA-Z0-9-_)
if err := util.ValidateUID(dsMapping.UID); err != nil {
logger.Error("Datasource UID validation failed",
"index", i,
"uid", dsMapping.UID,
"error", err)
w.WriteHeader(http.StatusBadRequest)
return json.NewEncoder(w).Encode(map[string]string{
"error": fmt.Sprintf("invalid datasource UID: %v", err),
"code": "invalid_datasource_uid",
})
}
// Validate type is not empty
if len(dsMapping.Type) == 0 {
logger.Error("Datasource type is empty", "index", i)
w.WriteHeader(http.StatusBadRequest)
return json.NewEncoder(w).Encode(map[string]string{
"error": "datasource type cannot be empty",
"code": "invalid_datasource_type",
})
}
}
// Step 2: Build validator request
validatorReq := validator.DashboardCompatibilityRequest{
DashboardJSON: req.DashboardJSON,
Datasources: make([]validator.Datasource, 0, len(req.DatasourceMappings)),
}
logger.Info("Processing request", "dashboardTitle", req.DashboardJSON["title"], "numMappings", len(req.DatasourceMappings))
// Get namespace from request (needed for datasource lookup)
// Namespace format is typically "org-{orgID}"
namespace := r.ResourceIdentifier.Namespace
// Extract orgID from namespace for logging context
orgID, err := getOrgIDFromNamespace(namespace)
if err != nil {
logger.Warn("Failed to parse namespace for orgID",
"namespace", namespace,
"error", err,
)
}
logger = logger.With("orgID", orgID, "namespace", namespace)
// Extract the requester once for per-datasource scoped permission checks
user, err := identity.GetRequester(ctx)
if err != nil {
logger.Error("Failed to get requester from context", "error", err)
w.WriteHeader(http.StatusUnauthorized)
return json.NewEncoder(w).Encode(map[string]string{
"error": "authentication required",
"code": "auth_error",
})
}
for _, dsMapping := range req.DatasourceMappings {
dsLogger := logger.With("datasourceUID", dsMapping.UID, "datasourceType", dsMapping.Type)
// Verify user has read/query access to this specific datasource
dsScope := datasources.ScopeProvider.GetResourceScopeUID(dsMapping.UID)
dsEvaluator := accesscontrol.EvalAll(
accesscontrol.EvalPermission(datasources.ActionRead, dsScope),
accesscontrol.EvalPermission(datasources.ActionQuery, dsScope),
)
hasAccess, err := ac.Evaluate(ctx, user, dsEvaluator)
if err != nil {
dsLogger.Error("Failed to evaluate datasource permissions", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return json.NewEncoder(w).Encode(map[string]string{
"error": "permission check failed",
"code": "auth_error",
})
}
if !hasAccess {
dsLogger.Warn("User lacks permission for datasource")
w.WriteHeader(http.StatusForbidden)
return json.NewEncoder(w).Encode(map[string]string{
"error": fmt.Sprintf("insufficient permissions for datasource: %s", dsMapping.UID),
"code": "datasource_forbidden",
})
}
// Convert optional name pointer to string
name := ""
if dsMapping.Name != nil {
name = *dsMapping.Name
dsLogger = dsLogger.With("datasourceName", name)
}
// Fetch datasource from Grafana using app-platform method
// Parameters: namespace, name (UID), group (datasource type)
ds, err := datasourceSvc.GetDataSourceInNamespace(ctx, namespace, dsMapping.UID, dsMapping.Type)
if err != nil {
dsLogger.Error("Failed to get datasource from namespace", "error", err)
// Check if it's a not found error vs other errors using proper type checking
statusCode := http.StatusInternalServerError
userMsg := fmt.Sprintf("failed to retrieve datasource: %s", dsMapping.UID)
if errors.Is(err, datasources.ErrDataSourceNotFound) {
statusCode = http.StatusNotFound
userMsg = fmt.Sprintf("datasource not found: %s (type: %s)", dsMapping.UID, dsMapping.Type)
dsLogger.Warn("Datasource not found in namespace")
}
w.WriteHeader(statusCode)
return json.NewEncoder(w).Encode(map[string]string{
"error": userMsg,
"code": "datasource_error",
})
}
dsLogger.Info("Retrieved datasource", "url", ds.URL, "actualType", ds.Type)
// Validate that the datasource type matches the expected type
if ds.Type != dsMapping.Type {
dsLogger.Error("Datasource type mismatch",
"expectedType", dsMapping.Type,
"actualType", ds.Type)
w.WriteHeader(http.StatusBadRequest)
return json.NewEncoder(w).Encode(map[string]string{
"error": fmt.Sprintf("datasource %s has type %s, expected %s", dsMapping.UID, ds.Type, dsMapping.Type),
"code": "datasource_wrong_type",
})
}
// Validate that this is a supported datasource type
// Supported types are determined by the validators map (single source of truth)
if _, supported := validators[ds.Type]; !supported {
dsLogger.Error("Unsupported datasource type", "type", ds.Type)
w.WriteHeader(http.StatusBadRequest)
return json.NewEncoder(w).Encode(map[string]string{
"error": fmt.Sprintf("datasource type '%s' is not supported (currently only 'prometheus' is supported)", ds.Type),
"code": "datasource_unsupported_type",
})
}
// Get authenticated HTTP transport for this datasource
transport, err := datasourceSvc.GetHTTPTransport(ctx, ds, httpClientProvider)
if err != nil {
dsLogger.Error("Failed to get HTTP transport for datasource", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return json.NewEncoder(w).Encode(map[string]string{
"error": fmt.Sprintf("failed to configure authentication for datasource: %s", dsMapping.UID),
"code": "datasource_config_error",
})
}
// Create HTTP client with authenticated transport and timeout
// The timeout acts as a safety net if context timeout isn't propagated
httpClient := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}
validatorReq.Datasources = append(validatorReq.Datasources, validator.Datasource{
UID: dsMapping.UID,
Type: dsMapping.Type,
Name: name,
URL: ds.URL,
HTTPClient: httpClient, // Pass authenticated client
})
dsLogger.Debug("Datasource configured successfully for validation")
}
// Step 3: Validate dashboard compatibility
result, err := validator.ValidateDashboardCompatibility(ctx, validatorReq, validators)
if err != nil {
logger.Error("Validation failed", "error", err)
// Check if it's a structured ValidationError with a specific status code
statusCode := http.StatusInternalServerError
errorCode := "validation_error"
errorMsg := fmt.Sprintf("validation failed: %v", err)
if validationErr := validator.GetValidationError(err); validationErr != nil {
statusCode = validationErr.StatusCode
errorCode = string(validationErr.Code)
errorMsg = validationErr.Message
// Log additional context from the error
for key, value := range validationErr.Details {
logger.Error("Validation error detail", key, value)
}
}
w.WriteHeader(statusCode)
return json.NewEncoder(w).Encode(map[string]string{
"error": errorMsg,
"code": errorCode,
})
}
// Step 4: Convert result to response format
response := convertToCheckResponse(result)
// Step 5: Return response
w.WriteHeader(http.StatusOK)
return json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Handler working!",
return json.NewEncoder(w).Encode(response)
}
}
// convertToCheckResponse converts validator result to API response format
func convertToCheckResponse(result *validator.DashboardCompatibilityResult) checkResponse {
response := checkResponse{
CompatibilityScore: result.CompatibilityScore,
DatasourceResults: make([]datasourceResult, 0, len(result.DatasourceResults)),
}
for _, dsResult := range result.DatasourceResults {
// Convert name string to pointer
var name *string
if dsResult.Name != "" {
name = &dsResult.Name
}
// Convert query results
queryBreakdown := make([]queryResult, 0, len(dsResult.QueryBreakdown))
for _, qr := range dsResult.QueryBreakdown {
queryBreakdown = append(queryBreakdown, queryResult{
PanelTitle: qr.PanelTitle,
PanelID: qr.PanelID,
QueryRefID: qr.QueryRefID,
TotalMetrics: qr.TotalMetrics,
FoundMetrics: qr.FoundMetrics,
MissingMetrics: qr.MissingMetrics,
CompatibilityScore: qr.CompatibilityScore,
ParseError: qr.ParseError,
})
}
response.DatasourceResults = append(response.DatasourceResults, datasourceResult{
UID: dsResult.UID,
Type: dsResult.Type,
Name: name,
TotalQueries: dsResult.TotalQueries,
CheckedQueries: dsResult.CheckedQueries,
TotalMetrics: dsResult.TotalMetrics,
FoundMetrics: dsResult.FoundMetrics,
MissingMetrics: dsResult.MissingMetrics,
QueryBreakdown: queryBreakdown,
CompatibilityScore: dsResult.CompatibilityScore,
})
}
return response
}
// getOrgIDFromNamespace extracts the org ID from a namespace using the standard authlib parser.
func getOrgIDFromNamespace(namespace string) (int64, error) {
info, err := types.ParseNamespace(namespace)
if err != nil {
return 0, fmt.Errorf("failed to parse namespace %s: %w", namespace, err)
}
return info.OrgID, nil
}
func GetKinds() map[schema.GroupVersion][]resource.Kind {
gv := schema.GroupVersion{
Group: "dashvalidator.grafana.app",
Version: "v1alpha1",
}
return map[schema.GroupVersion][]resource.Kind{
gv: {validatorv1alpha1.DashboardCompatibilityScoreKind()},
}
return map[schema.GroupVersion][]resource.Kind{}
}
@@ -0,0 +1,491 @@
package app
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana-app-sdk/resource"
"github.com/grafana/grafana/apps/dashvalidator/pkg/validator"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ============================================================================
// Datasource UID Validation Tests
// ============================================================================
func TestHandleCheck_InvalidDatasourceUID(t *testing.T) {
tests := []struct {
name string
uid string
expectedError string
}{
{
name: "empty UID",
uid: "",
expectedError: "UID is empty",
},
{
name: "UID too long",
uid: strings.Repeat("a", util.MaxUIDLength+1),
expectedError: "UID is longer than",
},
{
name: "UID with spaces",
uid: "invalid uid",
expectedError: "invalid format",
},
{
name: "UID with @ symbol",
uid: "invalid@uid",
expectedError: "invalid format",
},
{
name: "UID with ! symbol",
uid: "invalid!uid",
expectedError: "invalid format",
},
{
name: "UID with unicode",
uid: "invalid\u00e9uid",
expectedError: "invalid format",
},
{
name: "UID with dots",
uid: "invalid.uid",
expectedError: "invalid format",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := checkRequest{
DashboardJSON: map[string]interface{}{"title": "Test Dashboard"},
DatasourceMappings: []datasourceMapping{
{UID: tt.uid, Type: "prometheus"},
},
}
recorder := executeValidationRequest(t, body)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
var response map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, "invalid_datasource_uid", response["code"])
assert.Contains(t, response["error"], tt.expectedError)
})
}
}
func TestHandleCheck_ValidDatasourceUID(t *testing.T) {
tests := []struct {
name string
uid string
}{
{"lowercase letters", "abcdefg"},
{"uppercase letters", "ABCDEFG"},
{"numbers", "1234567"},
{"hyphens", "test-uid"},
{"underscores", "test_uid"},
{"mixed characters", "Test-UID_123"},
{"max length UID", strings.Repeat("a", util.MaxUIDLength)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := checkRequest{
DashboardJSON: map[string]interface{}{"title": "Test Dashboard"},
DatasourceMappings: []datasourceMapping{
{UID: tt.uid, Type: "prometheus"},
},
}
recorder := executeValidationRequest(t, body)
// Valid UIDs should NOT return invalid_datasource_uid error
// They may fail later (e.g., datasource not found), but not at validation
if recorder.Code == http.StatusBadRequest {
var response map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
assert.NotEqual(t, "invalid_datasource_uid", response["code"],
"Valid UID %q should not fail UID validation", tt.uid)
}
})
}
}
// ============================================================================
// Datasource Type Validation Tests
// ============================================================================
func TestHandleCheck_EmptyDatasourceType(t *testing.T) {
body := checkRequest{
DashboardJSON: map[string]interface{}{"title": "Test Dashboard"},
DatasourceMappings: []datasourceMapping{
{UID: "valid-uid", Type: ""},
},
}
recorder := executeValidationRequest(t, body)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
var response map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, "invalid_datasource_type", response["code"])
assert.Contains(t, response["error"], "datasource type cannot be empty")
}
// ============================================================================
// Request Parsing Tests
// ============================================================================
func TestHandleCheck_InvalidJSON(t *testing.T) {
bodyBytes := []byte(`{invalid json`)
requestURL, err := url.Parse("http://localhost:3000/apis/dashvalidator.grafana.app/v1alpha1/namespaces/org-1/check")
require.NoError(t, err)
req := &app.CustomRouteRequest{
ResourceIdentifier: resource.FullIdentifier{
Namespace: "org-1",
},
Path: "check",
URL: requestURL,
Method: "POST",
Headers: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewReader(bodyBytes)),
}
recorder := httptest.NewRecorder()
handler := handleCheckRoute(
logging.DefaultLogger,
nil, nil,
map[string]validator.DatasourceValidator{},
nil, // ac - not reached for invalid JSON
)
_ = handler(context.Background(), recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
var response map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
assert.Contains(t, response["error"], "invalid JSON")
}
func TestHandleCheck_MultipleDatasources(t *testing.T) {
body := checkRequest{
DashboardJSON: map[string]interface{}{"title": "Test"},
DatasourceMappings: []datasourceMapping{
{UID: "ds-1", Type: "prometheus"},
{UID: "ds-2", Type: "prometheus"},
},
}
recorder := executeValidationRequest(t, body)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
var response map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
assert.Contains(t, response["error"], "MVP only supports single datasource")
}
func TestHandleCheck_ZeroDatasources(t *testing.T) {
body := checkRequest{
DashboardJSON: map[string]interface{}{"title": "Test"},
DatasourceMappings: []datasourceMapping{},
}
recorder := executeValidationRequest(t, body)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
var response map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
assert.Contains(t, response["error"], "MVP only supports single datasource")
}
// ============================================================================
// Datasource Scoped Permission Tests
// ============================================================================
func TestHandleCheck_DatasourcePermissions(t *testing.T) {
ac := acimpl.ProvideAccessControl(nil)
// Valid request body — passes input validation, reaches the permission check
body := checkRequest{
DashboardJSON: map[string]any{"title": "Test Dashboard"},
DatasourceMappings: []datasourceMapping{
{UID: "target-ds", Type: "prometheus"},
},
}
bodyBytes, err := json.Marshal(body)
require.NoError(t, err)
// Helper to build the handler request with a fresh body reader
makeRequest := func(ctx context.Context) (*httptest.ResponseRecorder, *app.CustomRouteRequest) {
requestURL, _ := url.Parse("http://localhost:3000/apis/dashvalidator.grafana.app/v1alpha1/namespaces/org-1/check")
req := &app.CustomRouteRequest{
ResourceIdentifier: resource.FullIdentifier{Namespace: "org-1"},
Path: "check",
URL: requestURL,
Method: "POST",
Headers: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewReader(bodyBytes)),
}
return httptest.NewRecorder(), req
}
t.Run("no identity in context returns 401", func(t *testing.T) {
handler := handleCheckRoute(logging.DefaultLogger, nil, nil, map[string]validator.DatasourceValidator{}, ac)
recorder, req := makeRequest(context.Background())
_ = handler(context.Background(), recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
var resp map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
assert.Equal(t, "auth_error", resp["code"])
})
t.Run("user with scoped access passes permission check", func(t *testing.T) {
ctx := identity.WithRequester(context.TODO(), &identity.StaticRequester{
OrgRole: identity.RoleEditor,
UserID: 1,
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {
datasources.ActionRead: {"datasources:uid:target-ds"},
datasources.ActionQuery: {"datasources:uid:target-ds"},
},
},
})
handler := handleCheckRoute(logging.DefaultLogger, nil, nil, map[string]validator.DatasourceValidator{}, ac)
recorder, req := makeRequest(ctx)
// Will panic on nil datasourceSvc AFTER passing the permission check
func() {
defer func() { _ = recover() }()
_ = handler(ctx, recorder, req)
}()
// Should NOT be 401 or 403 — permission check passed
assert.NotEqual(t, http.StatusUnauthorized, recorder.Code)
assert.NotEqual(t, http.StatusForbidden, recorder.Code)
})
// the "user lacks scoped access" test case
t.Run("user without scoped access returns 403", func(t *testing.T) {
ctx := identity.WithRequester(context.TODO(), &identity.StaticRequester{
OrgRole: identity.RoleEditor,
UserID: 1,
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {
datasources.ActionRead: {"datasources:uid:not-target-ds"},
datasources.ActionQuery: {"datasources:uid:not-target-ds"},
},
},
})
handler := handleCheckRoute(logging.DefaultLogger, nil, nil, map[string]validator.DatasourceValidator{}, ac)
recorder, req := makeRequest(ctx)
_ = handler(ctx, recorder, req)
assert.Equal(t, http.StatusForbidden, recorder.Code)
var resp map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
assert.Equal(t, "datasource_forbidden", resp["code"])
})
}
// ============================================================================
// Response Conversion Tests (transforms data structure)
// ============================================================================
func TestConvertToCheckResponse(t *testing.T) {
t.Run("converts basic result correctly", func(t *testing.T) {
input := &validator.DashboardCompatibilityResult{
CompatibilityScore: 0.85,
DatasourceResults: []validator.DatasourceValidationResult{
{
UID: "ds-1",
Type: "prometheus",
Name: "My Prometheus",
ValidationResult: validator.ValidationResult{
TotalQueries: 10,
CheckedQueries: 10,
CompatibilityResult: validator.CompatibilityResult{
TotalMetrics: 20,
FoundMetrics: 17,
MissingMetrics: []string{"missing_1", "missing_2", "missing_3"},
CompatibilityScore: 0.85,
},
QueryBreakdown: []validator.QueryResult{
{
PanelTitle: "CPU Usage",
PanelID: 1,
QueryRefID: "A",
CompatibilityResult: validator.CompatibilityResult{
TotalMetrics: 5,
FoundMetrics: 4,
MissingMetrics: []string{"missing_1"},
CompatibilityScore: 0.8,
},
},
},
},
},
},
}
result := convertToCheckResponse(input)
assert.Equal(t, 0.85, result.CompatibilityScore)
require.Len(t, result.DatasourceResults, 1)
dsResult := result.DatasourceResults[0]
assert.Equal(t, "ds-1", dsResult.UID)
assert.Equal(t, "prometheus", dsResult.Type)
require.NotNil(t, dsResult.Name)
assert.Equal(t, "My Prometheus", *dsResult.Name)
assert.Equal(t, 10, dsResult.TotalQueries)
assert.Equal(t, 17, dsResult.FoundMetrics)
assert.Equal(t, []string{"missing_1", "missing_2", "missing_3"}, dsResult.MissingMetrics)
require.Len(t, dsResult.QueryBreakdown, 1)
qr := dsResult.QueryBreakdown[0]
assert.Equal(t, "CPU Usage", qr.PanelTitle)
assert.Equal(t, 1, qr.PanelID)
assert.Equal(t, "A", qr.QueryRefID)
assert.Nil(t, qr.ParseError)
})
t.Run("handles empty name as nil pointer", func(t *testing.T) {
input := &validator.DashboardCompatibilityResult{
CompatibilityScore: 1.0,
DatasourceResults: []validator.DatasourceValidationResult{
{
UID: "ds-1",
Type: "prometheus",
Name: "", // Empty name
},
},
}
result := convertToCheckResponse(input)
require.Len(t, result.DatasourceResults, 1)
assert.Nil(t, result.DatasourceResults[0].Name, "Empty name should become nil pointer")
})
t.Run("handles parse error in query result", func(t *testing.T) {
parseErr := "failed to parse PromQL"
input := &validator.DashboardCompatibilityResult{
CompatibilityScore: 0.5,
DatasourceResults: []validator.DatasourceValidationResult{
{
UID: "ds-1",
Type: "prometheus",
ValidationResult: validator.ValidationResult{
QueryBreakdown: []validator.QueryResult{
{
PanelTitle: "Broken Query",
ParseError: &parseErr,
},
},
},
},
},
}
result := convertToCheckResponse(input)
require.Len(t, result.DatasourceResults, 1)
require.Len(t, result.DatasourceResults[0].QueryBreakdown, 1)
require.NotNil(t, result.DatasourceResults[0].QueryBreakdown[0].ParseError)
assert.Equal(t, "failed to parse PromQL", *result.DatasourceResults[0].QueryBreakdown[0].ParseError)
})
t.Run("handles empty datasource results", func(t *testing.T) {
input := &validator.DashboardCompatibilityResult{
CompatibilityScore: 1.0,
DatasourceResults: []validator.DatasourceValidationResult{},
}
result := convertToCheckResponse(input)
assert.Equal(t, 1.0, result.CompatibilityScore)
assert.Empty(t, result.DatasourceResults)
})
}
// ============================================================================
// Test Helper - Executes validation portion of handler only
// ============================================================================
// executeValidationRequest tests the validation logic in handleCheck.
// It uses a minimal handler setup that will fail after validation passes,
// allowing us to isolate and test the validation behavior.
func executeValidationRequest(t *testing.T, body checkRequest) *httptest.ResponseRecorder {
t.Helper()
bodyBytes, err := json.Marshal(body)
require.NoError(t, err)
requestURL, err := url.Parse("http://localhost:3000/apis/dashvalidator.grafana.app/v1alpha1/namespaces/org-1/check")
require.NoError(t, err)
req := &app.CustomRouteRequest{
ResourceIdentifier: resource.FullIdentifier{
Namespace: "org-1",
},
Path: "check",
URL: requestURL,
Method: "POST",
Headers: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewReader(bodyBytes)),
}
recorder := httptest.NewRecorder()
// Create handler with nil dependencies - validation happens before they're used
handler := handleCheckRoute(
logging.DefaultLogger,
nil, // datasourceSvc - not reached during validation failures
nil, // httpClientProvider
map[string]validator.DatasourceValidator{},
nil, // ac - not reached during validation failures
)
// For validation tests, we only care about responses written before the panic.
// Valid inputs will panic when hitting nil datasourceSvc, but that's after
// validation passes - the recorder already has the validation error response.
func() {
defer func() {
// Recover from nil pointer dereference that occurs after validation passes
_ = recover()
}()
_ = handler(context.Background(), recorder, req)
}()
return recorder
}
+126
View File
@@ -0,0 +1,126 @@
package cache
import (
"context"
"fmt"
"net/http"
"sync"
"time"
)
const (
defaultCleanupInterval = 10 * time.Minute
// DefaultMetricsCacheTTL is the default TTL for cached metrics.
// Providers can use this when created if they don't need custom TTL.
DefaultMetricsCacheTTL = 5 * time.Minute
)
// cacheEntry represents a cached metrics list with expiration time.
type cacheEntry struct {
metrics []string
expiresAt time.Time
}
// MetricsCache provides TTL-based caching for metrics fetched from datasources.
// It caches results per datasource UID and runs background cleanup
// to remove expired entries.
// Providers are registered via RegisterProvider and looked up by datasource type.
// Implements app.Runnable via Run() to manage the cleanup goroutine lifecycle.
type MetricsCache struct {
mu sync.RWMutex
entries map[string]*cacheEntry // key: datasourceUID
providers map[string]MetricsProvider // key: datasource type (e.g., "prometheus")
}
// NewMetricsCache creates a new MetricsCache.
// Use RegisterProvider to add providers for each datasource type.
func NewMetricsCache() *MetricsCache {
return &MetricsCache{
entries: make(map[string]*cacheEntry),
providers: make(map[string]MetricsProvider),
}
}
// RegisterProvider registers a MetricsProvider for a datasource type.
// This should be called during app initialization, before any GetMetrics calls.
// Panics if a provider is already registered for the given type.
func (c *MetricsCache) RegisterProvider(dsType string, provider MetricsProvider) {
c.mu.Lock()
defer c.mu.Unlock()
if _, exists := c.providers[dsType]; exists {
panic("provider already registered for datasource type: " + dsType)
}
c.providers[dsType] = provider
}
// Run implements app.Runnable. It runs the cleanup loop until the context is cancelled.
// This method blocks until the context is cancelled (when the app shuts down).
func (c *MetricsCache) Run(ctx context.Context) error {
ticker := time.NewTicker(defaultCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
c.cleanupExpired()
}
}
}
// GetMetrics fetches metrics from cache or delegates to the appropriate provider.
// The provider is looked up from the registered providers by datasource type.
// On cache hit (non-expired entry), returns cached metrics immediately.
// On cache miss or expiration, fetches from provider and caches the result.
func (c *MetricsCache) GetMetrics(ctx context.Context, dsType, datasourceUID, datasourceURL string,
client *http.Client) ([]string, error) {
// Check cache first (read lock)
c.mu.RLock()
cached, exists := c.entries[datasourceUID]
provider := c.providers[dsType]
c.mu.RUnlock()
if exists && time.Now().Before(cached.expiresAt) {
return cached.metrics, nil
}
// Verify provider exists
if provider == nil {
return nil, fmt.Errorf("no metrics provider registered for datasource type: %s", dsType)
}
// Cache miss or expired - fetch from provider
result, err := provider.GetMetrics(ctx, datasourceUID, datasourceURL, client)
if err != nil {
return nil, err
}
// Don't cache results with zero TTL
if result.TTL > 0 {
c.mu.Lock()
c.entries[datasourceUID] = &cacheEntry{
metrics: result.Metrics,
expiresAt: time.Now().Add(result.TTL),
}
c.mu.Unlock()
}
return result.Metrics, nil
}
// cleanupExpired removes expired entries from the cache.
func (c *MetricsCache) cleanupExpired() {
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
for key, entry := range c.entries {
if now.After(entry.expiresAt) {
delete(c.entries, key)
}
}
}
+325
View File
@@ -0,0 +1,325 @@
package cache
import (
"context"
"errors"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// mockProvider implements MetricsProvider for testing
type mockProvider struct {
mu sync.Mutex
callCount int
metrics []string
ttl time.Duration
err error
}
func (m *mockProvider) GetMetrics(ctx context.Context, datasourceUID, datasourceURL string,
client *http.Client) (*MetricsResult, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.callCount++
if m.err != nil {
return nil, m.err
}
return &MetricsResult{
Metrics: m.metrics,
TTL: m.ttl,
}, nil
}
func (m *mockProvider) getCallCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.callCount
}
// setupTest creates a new MetricsCache and registers a mock provider for "test" type
func setupTest(mockProv *mockProvider) *MetricsCache {
cache := NewMetricsCache()
cache.RegisterProvider("test", mockProv)
return cache
}
// ============================================================================
// Category 1: Cache Hit/Miss Behavior
// ============================================================================
func TestMetricsCache_CacheMiss_FetchesFromProvider(t *testing.T) {
mockProv := &mockProvider{
metrics: []string{"metric_a", "metric_b"},
ttl: 5 * time.Minute,
}
cache := setupTest(mockProv)
metrics, err := cache.GetMetrics(context.Background(), "test", "ds-uid-1", "http://prom:9090", nil)
require.NoError(t, err)
require.ElementsMatch(t, []string{"metric_a", "metric_b"}, metrics)
require.Equal(t, 1, mockProv.getCallCount())
}
func TestMetricsCache_CacheHit_DoesNotFetchAgain(t *testing.T) {
mockProv := &mockProvider{
metrics: []string{"metric_a", "metric_b"},
ttl: 5 * time.Minute,
}
cache := setupTest(mockProv)
// First call - cache miss
metrics1, err := cache.GetMetrics(context.Background(), "test", "ds-uid-1", "http://prom:9090", nil)
require.NoError(t, err)
require.Equal(t, 1, mockProv.getCallCount())
// Second call - cache hit
metrics2, err := cache.GetMetrics(context.Background(), "test", "ds-uid-1", "http://prom:9090", nil)
require.NoError(t, err)
require.ElementsMatch(t, metrics1, metrics2)
require.Equal(t, 1, mockProv.getCallCount()) // Still 1, no new call
}
func TestMetricsCache_DifferentDatasources_SeparateCacheEntries(t *testing.T) {
mockProv := &mockProvider{
metrics: []string{"metric_a"},
ttl: 5 * time.Minute,
}
cache := setupTest(mockProv)
// First datasource
_, err := cache.GetMetrics(context.Background(), "test", "ds-uid-1", "http://prom1:9090", nil)
require.NoError(t, err)
require.Equal(t, 1, mockProv.getCallCount())
// Second datasource - separate cache entry
_, err = cache.GetMetrics(context.Background(), "test", "ds-uid-2", "http://prom2:9090", nil)
require.NoError(t, err)
require.Equal(t, 2, mockProv.getCallCount())
// First datasource again - cache hit
_, err = cache.GetMetrics(context.Background(), "test", "ds-uid-1", "http://prom1:9090", nil)
require.NoError(t, err)
require.Equal(t, 2, mockProv.getCallCount()) // Still 2
}
// ============================================================================
// Category 2: TTL Expiration
// ============================================================================
func TestMetricsCache_ExpiredEntry_FetchesAgain(t *testing.T) {
mockProv := &mockProvider{
metrics: []string{"metric_a"},
ttl: 10 * time.Millisecond, // Very short TTL
}
cache := setupTest(mockProv)
// First call
_, err := cache.GetMetrics(context.Background(), "test", "ds-uid-1", "http://prom:9090", nil)
require.NoError(t, err)
require.Equal(t, 1, mockProv.getCallCount())
// Wait for TTL to expire
time.Sleep(20 * time.Millisecond)
// Second call after expiration
_, err = cache.GetMetrics(context.Background(), "test", "ds-uid-1", "http://prom:9090", nil)
require.NoError(t, err)
require.Equal(t, 2, mockProv.getCallCount()) // New call made
}
func TestMetricsCache_ZeroTTL_DoesNotCache(t *testing.T) {
mockProv := &mockProvider{
metrics: []string{"metric_a"},
ttl: 0, // Zero TTL - don't cache
}
cache := setupTest(mockProv)
// First call
_, err := cache.GetMetrics(context.Background(), "test", "ds-uid-1", "http://prom:9090", nil)
require.NoError(t, err)
require.Equal(t, 1, mockProv.getCallCount())
// Second call - should fetch again since zero TTL means no caching
_, err = cache.GetMetrics(context.Background(), "test", "ds-uid-1", "http://prom:9090", nil)
require.NoError(t, err)
require.Equal(t, 2, mockProv.getCallCount())
}
// ============================================================================
// Category 3: Error Handling
// ============================================================================
func TestMetricsCache_ProviderError_ReturnsError(t *testing.T) {
providerErr := errors.New("connection refused")
mockProv := &mockProvider{
err: providerErr,
}
cache := setupTest(mockProv)
metrics, err := cache.GetMetrics(context.Background(), "test", "ds-uid-1", "http://prom:9090", nil)
require.Error(t, err)
require.ErrorIs(t, err, providerErr)
require.Nil(t, metrics)
require.Equal(t, 1, mockProv.getCallCount())
}
func TestMetricsCache_ProviderError_DoesNotCache(t *testing.T) {
providerErr := errors.New("connection refused")
mockProv := &mockProvider{
err: providerErr,
}
cache := setupTest(mockProv)
// First call - error
_, err := cache.GetMetrics(context.Background(), "test", "ds-uid-1", "http://prom:9090", nil)
require.Error(t, err)
require.Equal(t, 1, mockProv.getCallCount())
// Clear error for next call
mockProv.mu.Lock()
mockProv.err = nil
mockProv.metrics = []string{"metric_a"}
mockProv.ttl = 5 * time.Minute
mockProv.mu.Unlock()
// Second call - should fetch again (error not cached)
metrics, err := cache.GetMetrics(context.Background(), "test", "ds-uid-1", "http://prom:9090", nil)
require.NoError(t, err)
require.NotNil(t, metrics)
require.Equal(t, 2, mockProv.getCallCount())
}
// ============================================================================
// Category 4: Concurrent Access
// ============================================================================
func TestMetricsCache_ConcurrentAccess_ThreadSafe(t *testing.T) {
mockProv := &mockProvider{
metrics: []string{"metric_a", "metric_b"},
ttl: 5 * time.Minute,
}
cache := setupTest(mockProv)
// Run concurrent requests
var wg sync.WaitGroup
errCount := atomic.Int32{}
for i := range 100 {
wg.Add(1)
go func(idx int) {
defer wg.Done()
// Alternate between datasources to test cache isolation
uid := "ds-uid-1"
if idx%2 == 0 {
uid = "ds-uid-2"
}
metrics, err := cache.GetMetrics(context.Background(), "test", uid, "http://prom:9090", nil)
if err != nil {
errCount.Add(1)
return
}
if len(metrics) != 2 {
errCount.Add(1)
}
}(i)
}
wg.Wait()
require.Equal(t, int32(0), errCount.Load())
// Without request coalescing (singleflight), multiple goroutines may see a cache miss
// before any result is cached. We verify the cache eventually works (low call count)
// rather than exactly 2 calls. With 100 requests for 2 UIDs, we expect at most a few
// calls per UID during the initial thundering herd.
callCount := mockProv.getCallCount()
require.GreaterOrEqual(t, callCount, 2, "should have at least 2 calls (one per UID)")
require.LessOrEqual(t, callCount, 10, "should have at most 10 calls (cache should help)")
}
// ============================================================================
// Category 5: Cleanup Behavior
// ============================================================================
func TestMetricsCache_CleanupRemovesExpiredEntries(t *testing.T) {
mockProv := &mockProvider{
metrics: []string{"metric_a"},
ttl: 10 * time.Millisecond, // Very short TTL
}
cache := setupTest(mockProv)
// Populate cache
_, err := cache.GetMetrics(context.Background(), "test", "ds-uid-1", "http://prom:9090", nil)
require.NoError(t, err)
require.Equal(t, 1, mockProv.getCallCount())
// Verify entry exists
cache.mu.RLock()
_, exists := cache.entries["ds-uid-1"]
cache.mu.RUnlock()
require.True(t, exists)
// Wait for TTL to expire
time.Sleep(20 * time.Millisecond)
// Trigger cleanup
cache.cleanupExpired()
// Verify entry was removed
cache.mu.RLock()
_, exists = cache.entries["ds-uid-1"]
cache.mu.RUnlock()
require.False(t, exists)
}
func TestMetricsCache_RunStopsOnContextCancel(t *testing.T) {
mockProv := &mockProvider{
metrics: []string{"metric_a"},
ttl: 5 * time.Minute,
}
cache := setupTest(mockProv)
ctx, cancel := context.WithCancel(context.Background())
// Run in goroutine
done := make(chan error)
go func() {
done <- cache.Run(ctx)
}()
// Cancel context
cancel()
// Verify Run() exits
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("Run() did not exit after context cancellation")
}
}
// ============================================================================
// Category 6: Provider Registration Behavior
// ============================================================================
func TestMetricsCache_RegisterProvider_DuplicateType_Panics(t *testing.T) {
mockProv := &mockProvider{
metrics: []string{"metric_a"},
ttl: 5 * time.Minute,
}
cache := NewMetricsCache()
// First registration succeeds
cache.RegisterProvider("duplicate", mockProv)
// Second registration panics
require.Panics(t, func() {
cache.RegisterProvider("duplicate", mockProv)
})
}
+24
View File
@@ -0,0 +1,24 @@
package cache
import (
"context"
"net/http"
"time"
)
// MetricsProvider defines the interface for fetching available metrics
// from any datasource type (Prometheus, Loki, Mimir, etc.).
// Each datasource type implements its own provider.
type MetricsProvider interface {
// GetMetrics fetches available metric names from the datasource.
// Returns the metrics list and recommended TTL for caching.
// The client parameter should have proper authentication configured.
GetMetrics(ctx context.Context, datasourceUID, datasourceURL string,
client *http.Client) (*MetricsResult, error)
}
// MetricsResult contains fetched metrics and recommended TTL for caching.
type MetricsResult struct {
Metrics []string
TTL time.Duration
}
@@ -0,0 +1,519 @@
package validator
import (
"context"
"fmt"
"strings"
)
// DashboardCompatibilityRequest contains the dashboard and datasources to validate
type DashboardCompatibilityRequest struct {
DashboardJSON map[string]interface{} // Dashboard JSON structure
Datasources []Datasource // List of datasources to validate against
}
// DashboardCompatibilityResult contains the validation results for a dashboard
type DashboardCompatibilityResult struct {
CompatibilityScore float64 // Overall compatibility (0.0 - 1.0)
DatasourceResults []DatasourceValidationResult // Per-datasource results
}
// DatasourceValidationResult contains validation results for one datasource.
// It embeds ValidationResult and adds datasource identification fields.
type DatasourceValidationResult struct {
ValidationResult // Embedded: contains all validation metrics
UID string `json:"uid"` // Datasource UID
Type string `json:"type"` // Datasource type (prometheus, mysql, etc.)
Name string `json:"name"` // Datasource name for display
}
// 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
// validators is a map of datasource type -> validator (e.g., "prometheus" -> PrometheusValidator)
func ValidateDashboardCompatibility(ctx context.Context, req DashboardCompatibilityRequest, validators map[string]DatasourceValidator) (*DashboardCompatibilityResult, error) {
// MVP: Only support single datasource validation
if len(req.Datasources) != 1 {
return nil, fmt.Errorf("MVP only supports single datasource validation, got %d datasources", len(req.Datasources))
}
singleDatasource := req.Datasources[0]
result := &DashboardCompatibilityResult{
DatasourceResults: make([]DatasourceValidationResult, 0, len(req.Datasources)),
}
// Step 1: Extract queries from dashboard JSON
queries, err := extractQueriesFromDashboard(req.DashboardJSON)
if err != nil {
return nil, fmt.Errorf("failed to extract queries from dashboard: %w", err)
}
// Step 2: Group queries by datasource UID (with variable resolution for MVP)
queriesByDatasource := groupQueriesByDatasource(queries, singleDatasource.UID, req.DashboardJSON)
// Step 3: Validate each datasource
var totalCompatibility float64
validatedCount := 0
for _, ds := range req.Datasources {
// Get queries for this datasource
dsQueries, ok := queriesByDatasource[ds.UID]
if !ok || len(dsQueries) == 0 {
// No queries for this datasource, skip
continue
}
// Get validator for this datasource type
v, ok := validators[ds.Type]
if !ok {
// Unsupported datasource type, skip
continue
}
// Validate queries
validationResult, err := v.ValidateQueries(ctx, dsQueries, ds)
if err != nil {
// Validation failed for this datasource - return error to caller
// This could be a connection error, auth error, or other critical failure
return nil, fmt.Errorf("validation failed for datasource %s: %w", ds.UID, err)
}
// Build result using embedded ValidationResult
dsResult := DatasourceValidationResult{
ValidationResult: *validationResult,
UID: ds.UID,
Type: ds.Type,
Name: ds.Name,
}
result.DatasourceResults = append(result.DatasourceResults, dsResult)
totalCompatibility += validationResult.CompatibilityScore
validatedCount++
}
// Step 4: Calculate overall compatibility score
if validatedCount > 0 {
result.CompatibilityScore = totalCompatibility / float64(validatedCount)
} else {
result.CompatibilityScore = 1.0 // No datasources = perfect compatibility
}
return result, nil
}
// extractQueriesFromDashboard parses the dashboard JSON and extracts all queries
// Both formats v1 (legacy) and v2 (new) can be passed, but we only support v1 in MVP
func extractQueriesFromDashboard(dashboardJSON map[string]interface{}) ([]DashboardQuery, error) {
var queries []DashboardQuery
// Detect dashboard version (v1 uses "panels", v2 uses different structure)
// For MVP, we only support v1 (legacy format with panels array)
if !isV1Dashboard(dashboardJSON) {
return nil, fmt.Errorf("unsupported dashboard format: only v1 dashboards are supported in MVP")
}
// Extract panels array
panels, ok := dashboardJSON["panels"].([]interface{})
if !ok {
// No panels in dashboard, return empty array
return queries, nil
}
// Iterate through all panels
for _, panelInterface := range panels {
panel, ok := panelInterface.(map[string]interface{})
if !ok {
continue
}
// Extract queries from this panel
panelQueries := extractQueriesFromPanel(panel)
queries = append(queries, panelQueries...)
// Handle nested panels in collapsed rows
nestedPanels, hasNested := panel["panels"].([]interface{})
if hasNested {
for _, nestedPanelInterface := range nestedPanels {
nestedPanel, ok := nestedPanelInterface.(map[string]interface{})
if !ok {
continue
}
nestedQueries := extractQueriesFromPanel(nestedPanel)
queries = append(queries, nestedQueries...)
}
}
}
return queries, nil
}
// 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 {
// 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
}
// extractQueriesFromPanel extracts all queries/targets from a single panel
func extractQueriesFromPanel(panel map[string]interface{}) []DashboardQuery {
// Extract targets array (queries) first to know capacity
targets, hasTargets := panel["targets"].([]interface{})
if !hasTargets {
return nil
}
// Pre-allocate with capacity since we know max size
queries := make([]DashboardQuery, 0, len(targets))
// Get panel info for context
panelTitle := getStringValue(panel, "title", "Untitled Panel")
panelID := getIntValue(panel, "id", 0)
// Iterate through each target/query
for _, targetInterface := range targets {
target, ok := targetInterface.(map[string]interface{})
if !ok {
continue
}
// Extract datasource UID
datasourceUID := extractDatasourceUID(target, panel)
if datasourceUID == "" {
// Skip queries without datasource
continue
}
// Extract query text (different fields for different datasources)
queryText := extractQueryText(target)
if queryText == "" {
// Skip empty queries
continue
}
// Extract refId (A, B, C, etc.)
refID := getStringValue(target, "refId", "")
// Build DashboardQuery
query := DashboardQuery{
DatasourceUID: datasourceUID,
RefID: refID,
QueryText: queryText,
PanelTitle: panelTitle,
PanelID: panelID,
}
queries = append(queries, query)
}
return queries
}
// extractDatasourceUID gets the datasource UID from a target, falling back to panel datasource
func extractDatasourceUID(target map[string]interface{}, panel map[string]interface{}) string {
// Try target-level datasource first
if ds, ok := target["datasource"]; ok {
if uid := getDatasourceUIDFromValue(ds); uid != "" {
return uid
}
}
// Fall back to panel-level datasource
if ds, ok := panel["datasource"]; ok {
if uid := getDatasourceUIDFromValue(ds); uid != "" {
return uid
}
}
return ""
}
// getDatasourceUIDFromValue extracts UID from datasource value (can be string or object)
func getDatasourceUIDFromValue(ds interface{}) string {
switch v := ds.(type) {
case string:
// Direct UID string
return v
case map[string]interface{}:
// Structured datasource reference { uid: "...", type: "..." }
return getStringValue(v, "uid", "")
default:
return ""
}
}
// isWordChar checks if a character is a valid variable name character.
// Matches \w in regex: [A-Za-z0-9_] (alphanumeric + underscore, NO dashes)
func isWordChar(ch rune) bool {
return (ch >= 'a' && ch <= 'z') ||
(ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9') ||
ch == '_'
}
// isDollarBraceVar checks if string matches ${varname} pattern.
// Supports ${var}, ${var.field}, and ${var:format} syntax.
func isDollarBraceVar(s string) bool {
if len(s) <= 3 || s[0] != '$' || s[1] != '{' || s[len(s)-1] != '}' {
return false
}
content := s[2 : len(s)-1]
if len(content) == 0 {
return false
}
for i, ch := range content {
if ch == '.' || ch == ':' {
return i > 0
}
if !isWordChar(ch) {
return false
}
}
return true
}
// isDollarVar checks if string matches $varname pattern.
func isDollarVar(s string) bool {
if len(s) <= 1 || s[0] != '$' {
return false
}
// Avoid matching ${...} pattern
if s[1] == '{' {
return false
}
for i := 1; i < len(s); i++ {
if !isWordChar(rune(s[i])) {
return false
}
}
return true
}
// isDoubleBracketVar checks if string matches [[varname]] pattern.
// Supports [[var]] and [[var:format]] syntax.
func isDoubleBracketVar(s string) bool {
if len(s) <= 4 || s[0] != '[' || s[1] != '[' || s[len(s)-2] != ']' || s[len(s)-1] != ']' {
return false
}
content := s[2 : len(s)-2]
if len(content) == 0 {
return false
}
for i, ch := range content {
if ch == ':' {
return i > 0
}
if !isWordChar(ch) {
return false
}
}
return true
}
// 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
}
return isDollarBraceVar(uid) || isDollarVar(uid) || isDoubleBracketVar(uid)
}
// 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) {
return singleDatasourceUID
}
// Non-Prometheus variable, return as-is (will be ignored in grouping)
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 {
// Try common query field names
queryFields := []string{"expr", "query", "rawSql", "rawQuery", "target", "measurement"}
for _, field := range queryFields {
if queryText := getStringValue(target, field, ""); queryText != "" {
return queryText
}
}
return ""
}
// getStringValue safely extracts a string value from a map
func getStringValue(m map[string]interface{}, key string, defaultValue string) string {
if value, ok := m[key]; ok {
if s, ok := value.(string); ok {
return s
}
}
return defaultValue
}
// getIntValue safely extracts an int value from a map
func getIntValue(m map[string]interface{}, key string, defaultValue int) int {
if value, ok := m[key]; ok {
switch v := value.(type) {
case int:
return v
case float64:
return int(v)
case int64:
return int(v)
}
}
return defaultValue
}
// DashboardQuery represents a query extracted from a dashboard panel
type DashboardQuery struct {
DatasourceUID string // Which datasource this query belongs to
RefID string // Query reference ID
QueryText string // The actual query
PanelTitle string // Panel title
PanelID int // Panel ID
}
// groupQueriesByDatasource groups dashboard queries by their datasource UID
// 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 {
q := Query{
RefID: dq.RefID,
QueryText: dq.QueryText,
PanelTitle: dq.PanelTitle,
PanelID: dq.PanelID,
}
// 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,604 @@
package validator
import (
"testing"
"github.com/stretchr/testify/require"
)
// Note: extractQueryText() uses a hardcoded field priority list because
// Grafana doesn't expose datasource query schemas at runtime.
// When Grafana adds new datasource types, update the list in dashboard.go
// and add corresponding test cases here.
// =============================================================================
// Category 1: extractQueryText Tests
// Tests verify the hardcoded field priority list works correctly.
// =============================================================================
func TestExtractQueryText(t *testing.T) {
tests := []struct {
name string
target map[string]interface{}
expected string
}{
{
name: "prometheus_expr_field",
target: map[string]interface{}{
"expr": "up",
},
expected: "up",
},
{
name: "mysql_rawSql_field",
target: map[string]interface{}{
"rawSql": "SELECT * FROM users LIMIT 100",
},
expected: "SELECT * FROM users LIMIT 100",
},
{
name: "generic_query_field",
target: map[string]interface{}{
"query": "show measurements",
},
expected: "show measurements",
},
{
name: "field_priority_order",
target: map[string]interface{}{
"expr": "rate(cpu[5m])", // First priority
"query": "show metrics", // Second priority
},
expected: "rate(cpu[5m])", // Should return expr, not query
},
{
name: "missing_query_fields",
target: map[string]interface{}{"refId": "A", "hide": false},
expected: "",
},
{
name: "empty_string_value",
target: map[string]interface{}{
"expr": "",
},
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractQueryText(tt.target)
require.Equal(t, tt.expected, result)
})
}
}
// =============================================================================
// Category 2: getDatasourceUIDFromValue Tests (4 tests)
// =============================================================================
func TestGetDatasourceUIDFromValue(t *testing.T) {
tests := []struct {
name string
value interface{}
expected string
}{
{
name: "string_datasource_uid",
value: "prom-123",
expected: "prom-123",
},
{
name: "object_datasource_with_uid",
value: map[string]interface{}{
"uid": "prom-123",
"type": "prometheus",
},
expected: "prom-123",
},
{
name: "variable_reference_passed_through",
value: "${DS_PROMETHEUS}",
expected: "${DS_PROMETHEUS}",
},
{
name: "nil_value",
value: nil,
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getDatasourceUIDFromValue(tt.value)
require.Equal(t, tt.expected, result)
})
}
}
// =============================================================================
// Category 3: extractDatasourceUID Tests (5 tests)
// =============================================================================
func TestExtractDatasourceUID(t *testing.T) {
tests := []struct {
name string
target map[string]interface{}
panel map[string]interface{}
expected string
}{
{
name: "target_level_datasource_string",
target: map[string]interface{}{
"datasource": "target-ds-123",
},
panel: map[string]interface{}{},
expected: "target-ds-123",
},
{
name: "target_level_datasource_object",
target: map[string]interface{}{
"datasource": map[string]interface{}{
"uid": "target-ds-456",
"type": "prometheus",
},
},
panel: map[string]interface{}{},
expected: "target-ds-456",
},
{
name: "panel_level_fallback",
target: map[string]interface{}{},
panel: map[string]interface{}{
"datasource": "panel-ds-789",
},
expected: "panel-ds-789",
},
{
name: "target_level_takes_precedence",
target: map[string]interface{}{
"datasource": "target-ds",
},
panel: map[string]interface{}{
"datasource": "panel-ds",
},
expected: "target-ds",
},
{
name: "both_missing_returns_empty",
target: map[string]interface{}{},
panel: map[string]interface{}{},
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractDatasourceUID(tt.target, tt.panel)
require.Equal(t, tt.expected, result)
})
}
}
// =============================================================================
// Category 4: extractQueriesFromPanel Tests (8 tests)
// =============================================================================
func TestExtractQueriesFromPanel(t *testing.T) {
tests := []struct {
name string
panel map[string]interface{}
expected []DashboardQuery
}{
{
name: "panel_with_single_target",
panel: map[string]interface{}{
"id": 42,
"title": "CPU Usage",
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"expr": "rate(cpu[5m])",
"datasource": "prom-main",
},
},
},
expected: []DashboardQuery{
{
DatasourceUID: "prom-main",
RefID: "A",
QueryText: "rate(cpu[5m])",
PanelTitle: "CPU Usage",
PanelID: 42,
},
},
},
{
name: "panel_with_multiple_targets",
panel: map[string]interface{}{
"id": 10,
"title": "Metrics",
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"expr": "up",
"datasource": "prom-1",
},
map[string]interface{}{
"refId": "B",
"expr": "down",
"datasource": "prom-1",
},
},
},
expected: []DashboardQuery{
{
DatasourceUID: "prom-1",
RefID: "A",
QueryText: "up",
PanelTitle: "Metrics",
PanelID: 10,
},
{
DatasourceUID: "prom-1",
RefID: "B",
QueryText: "down",
PanelTitle: "Metrics",
PanelID: 10,
},
},
},
{
name: "panel_with_no_targets_field",
panel: map[string]interface{}{
"id": 1,
"title": "Text Panel",
},
expected: []DashboardQuery{},
},
{
name: "panel_with_empty_targets_array",
panel: map[string]interface{}{
"id": 2,
"title": "Empty",
"targets": []interface{}{},
},
expected: []DashboardQuery{},
},
{
name: "target_missing_datasource_skipped",
panel: map[string]interface{}{
"id": 3,
"title": "Incomplete",
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"expr": "up",
// No datasource field
},
},
},
expected: []DashboardQuery{}, // Empty because no datasource
},
{
name: "target_missing_query_text_skipped",
panel: map[string]interface{}{
"id": 4,
"title": "No Query",
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"datasource": "prom-1",
// No expr/query field
},
},
},
expected: []DashboardQuery{}, // Empty because no query text
},
{
name: "panel_metadata_extraction",
panel: map[string]interface{}{
"id": 999,
"title": "Custom Title",
"targets": []interface{}{
map[string]interface{}{
"refId": "Z",
"expr": "test_metric",
"datasource": "ds-abc",
},
},
},
expected: []DashboardQuery{
{
DatasourceUID: "ds-abc",
RefID: "Z",
QueryText: "test_metric",
PanelTitle: "Custom Title",
PanelID: 999,
},
},
},
{
name: "panel_id_as_float64",
panel: map[string]interface{}{
"id": float64(123), // JSON numbers parse as float64
"title": "Float ID Panel",
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"expr": "metric",
"datasource": "ds-1",
},
},
},
expected: []DashboardQuery{
{
DatasourceUID: "ds-1",
RefID: "A",
QueryText: "metric",
PanelTitle: "Float ID Panel",
PanelID: 123,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractQueriesFromPanel(tt.panel)
if len(tt.expected) == 0 {
require.Empty(t, result)
} else {
require.Equal(t, tt.expected, result)
}
})
}
}
// =============================================================================
// Category 5: Helper Functions Tests
// =============================================================================
func TestGetStringValue(t *testing.T) {
tests := []struct {
name string
m map[string]interface{}
key string
defaultValue string
expected string
}{
{
name: "returns_value_if_exists",
m: map[string]interface{}{"name": "test"},
key: "name",
defaultValue: "default",
expected: "test",
},
{
name: "returns_default_if_missing",
m: map[string]interface{}{"other": "value"},
key: "name",
defaultValue: "default",
expected: "default",
},
{
name: "handles_non_string_type",
m: map[string]interface{}{"name": 123},
key: "name",
defaultValue: "default",
expected: "default",
},
{
name: "empty_map_returns_default",
m: map[string]interface{}{},
key: "name",
defaultValue: "default",
expected: "default",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getStringValue(tt.m, tt.key, tt.defaultValue)
require.Equal(t, tt.expected, result)
})
}
}
func TestGetIntValue(t *testing.T) {
tests := []struct {
name string
m map[string]interface{}
key string
defaultValue int
expected int
}{
{
name: "returns_int_value",
m: map[string]interface{}{"count": 42},
key: "count",
defaultValue: 0,
expected: 42,
},
{
name: "handles_float64_conversion",
m: map[string]interface{}{"count": float64(123)},
key: "count",
defaultValue: 0,
expected: 123,
},
{
name: "handles_int64_conversion",
m: map[string]interface{}{"count": int64(456)},
key: "count",
defaultValue: 0,
expected: 456,
},
{
name: "returns_default_for_missing",
m: map[string]interface{}{},
key: "count",
defaultValue: 99,
expected: 99,
},
{
name: "returns_default_for_invalid_type",
m: map[string]interface{}{"count": "not a number"},
key: "count",
defaultValue: 99,
expected: 99,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getIntValue(tt.m, tt.key, tt.defaultValue)
require.Equal(t, tt.expected, result)
})
}
}
// =============================================================================
// Category 6: Integration Tests
// Real-world dashboard panel structures
// =============================================================================
func TestRealisticPrometheusPanel(t *testing.T) {
// Realistic Prometheus panel from actual Grafana dashboard
panel := map[string]interface{}{
"datasource": map[string]interface{}{
"type": "prometheus",
"uid": "prometheus-main",
},
"gridPos": map[string]interface{}{
"h": 8,
"w": 12,
"x": 0,
"y": 0,
},
"id": 28,
"title": "Request Rate",
"type": "timeseries",
"targets": []interface{}{
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "prometheus",
"uid": "prometheus-main",
},
"expr": "rate(http_requests_total{job=\"api\"}[5m])",
"refId": "A",
"legendFormat": "{{method}} {{status}}",
"interval": "",
},
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "prometheus",
"uid": "prometheus-main",
},
"expr": "rate(http_requests_total{job=\"worker\"}[5m])",
"refId": "B",
"legendFormat": "{{method}}",
},
},
}
result := extractQueriesFromPanel(panel)
require.Len(t, result, 2)
require.Equal(t, "prometheus-main", result[0].DatasourceUID)
require.Equal(t, "A", result[0].RefID)
require.Equal(t, "rate(http_requests_total{job=\"api\"}[5m])", result[0].QueryText)
require.Equal(t, "Request Rate", result[0].PanelTitle)
require.Equal(t, 28, result[0].PanelID)
require.Equal(t, "prometheus-main", result[1].DatasourceUID)
require.Equal(t, "B", result[1].RefID)
require.Equal(t, "rate(http_requests_total{job=\"worker\"}[5m])", result[1].QueryText)
}
func TestRealisticMySQLPanel(t *testing.T) {
// Realistic MySQL panel structure
panel := map[string]interface{}{
"id": 10,
"title": "Recent Users",
"type": "table",
"datasource": map[string]interface{}{
"type": "mysql",
"uid": "mysql-prod",
},
"targets": []interface{}{
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "mysql",
"uid": "mysql-prod",
},
"refId": "A",
"rawSql": "SELECT id, username, email FROM users WHERE created_at > NOW() - INTERVAL 1 DAY ORDER BY created_at DESC LIMIT 100",
"format": "table",
},
},
}
result := extractQueriesFromPanel(panel)
require.Len(t, result, 1)
require.Equal(t, "mysql-prod", result[0].DatasourceUID)
require.Equal(t, "A", result[0].RefID)
require.Contains(t, result[0].QueryText, "SELECT id, username, email FROM users")
require.Equal(t, "Recent Users", result[0].PanelTitle)
require.Equal(t, 10, result[0].PanelID)
}
func TestMixedDatasourcesPanel(t *testing.T) {
// Panel with targets using different datasource types
panel := map[string]interface{}{
"id": 50,
"title": "Mixed Data",
"datasource": map[string]interface{}{
"type": "prometheus",
"uid": "default-prom",
},
"targets": []interface{}{
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "prometheus",
"uid": "prom-1",
},
"refId": "A",
"expr": "up",
},
map[string]interface{}{
"datasource": map[string]interface{}{
"type": "elasticsearch",
"uid": "elastic-1",
},
"refId": "B",
"query": "status:200",
},
map[string]interface{}{
// Uses panel-level datasource (fallback)
"refId": "C",
"expr": "down",
},
},
}
result := extractQueriesFromPanel(panel)
require.Len(t, result, 3)
// Prometheus query
require.Equal(t, "prom-1", result[0].DatasourceUID)
require.Equal(t, "A", result[0].RefID)
require.Equal(t, "up", result[0].QueryText)
// Elasticsearch query
require.Equal(t, "elastic-1", result[1].DatasourceUID)
require.Equal(t, "B", result[1].RefID)
require.Equal(t, "status:200", result[1].QueryText)
// Query with panel-level datasource fallback
require.Equal(t, "default-prom", result[2].DatasourceUID)
require.Equal(t, "C", result[2].RefID)
require.Equal(t, "down", result[2].QueryText)
}
@@ -0,0 +1,239 @@
package validator
import (
"encoding/json"
"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 TestDatasourceValidationResult_JSONSerialization(t *testing.T) {
// Verify that embedded struct produces the expected flat JSON structure
result := DatasourceValidationResult{
ValidationResult: ValidationResult{
TotalQueries: 10,
CheckedQueries: 10,
QueryBreakdown: []QueryResult{},
CompatibilityResult: CompatibilityResult{
TotalMetrics: 5,
FoundMetrics: 4,
MissingMetrics: []string{"missing_metric"},
CompatibilityScore: 0.8,
},
},
UID: "test-uid",
Type: "prometheus",
Name: "Test Datasource",
}
jsonBytes, err := json.Marshal(result)
require.NoError(t, err)
var parsed map[string]interface{}
err = json.Unmarshal(jsonBytes, &parsed)
require.NoError(t, err)
// Verify all fields are at top level (not nested)
require.Equal(t, "test-uid", parsed["uid"])
require.Equal(t, "prometheus", parsed["type"])
require.Equal(t, "Test Datasource", parsed["name"])
require.Equal(t, float64(10), parsed["totalQueries"])
require.Equal(t, float64(10), parsed["checkedQueries"])
require.Equal(t, float64(5), parsed["totalMetrics"])
require.Equal(t, float64(4), parsed["foundMetrics"])
require.Equal(t, float64(0.8), parsed["compatibilityScore"])
// Verify no nested "ValidationResult" key exists
_, hasNestedKey := parsed["ValidationResult"]
require.False(t, hasNestedKey, "ValidationResult should not be a nested key in JSON")
}
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")
}
})
}
}
+173
View File
@@ -0,0 +1,173 @@
package validator
import (
"errors"
"fmt"
"net/http"
)
// ErrorCode represents the type of error that occurred
type ErrorCode string
const (
// Datasource-related errors
ErrCodeDatasourceNotFound ErrorCode = "datasource_not_found"
ErrCodeDatasourceWrongType ErrorCode = "datasource_wrong_type"
ErrCodeDatasourceUnreachable ErrorCode = "datasource_unreachable"
ErrCodeDatasourceAuth ErrorCode = "datasource_auth_failed"
ErrCodeDatasourceConfig ErrorCode = "datasource_config_error"
// API-related errors
ErrCodeAPIUnavailable ErrorCode = "api_unavailable"
ErrCodeAPIInvalidResponse ErrorCode = "api_invalid_response"
ErrCodeAPIRateLimit ErrorCode = "api_rate_limit"
ErrCodeAPITimeout ErrorCode = "api_timeout"
// Validation errors
ErrCodeInvalidDashboard ErrorCode = "invalid_dashboard"
ErrCodeUnsupportedDashVersion ErrorCode = "unsupported_dashboard_version"
ErrCodeInvalidQuery ErrorCode = "invalid_query"
// Internal errors
ErrCodeInternal ErrorCode = "internal_error"
)
// ValidationError represents a structured error with context
type ValidationError struct {
Code ErrorCode
Message string
Details map[string]interface{}
StatusCode int
Cause error
}
// Error implements the error interface
func (e *ValidationError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %s (caused by: %v)", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
// Unwrap implements error unwrapping
func (e *ValidationError) Unwrap() error {
return e.Cause
}
// NewValidationError creates a new ValidationError
func NewValidationError(code ErrorCode, message string, statusCode int) *ValidationError {
return &ValidationError{
Code: code,
Message: message,
StatusCode: statusCode,
Details: make(map[string]interface{}),
}
}
// WithCause adds the underlying error cause
func (e *ValidationError) WithCause(err error) *ValidationError {
e.Cause = err
return e
}
// WithDetail adds contextual information
func (e *ValidationError) WithDetail(key string, value interface{}) *ValidationError {
e.Details[key] = value
return e
}
// Common error constructors
// NewDatasourceNotFoundError creates an error for datasource not found
func NewDatasourceNotFoundError(uid string, namespace string) *ValidationError {
return NewValidationError(
ErrCodeDatasourceNotFound,
fmt.Sprintf("datasource not found: %s", uid),
http.StatusNotFound,
).WithDetail("datasourceUID", uid).WithDetail("namespace", namespace)
}
// NewDatasourceWrongTypeError creates an error for wrong datasource type
func NewDatasourceWrongTypeError(uid string, expectedType string, actualType string) *ValidationError {
return NewValidationError(
ErrCodeDatasourceWrongType,
fmt.Sprintf("datasource %s has wrong type: expected %s, got %s", uid, expectedType, actualType),
http.StatusBadRequest,
).WithDetail("datasourceUID", uid).
WithDetail("expectedType", expectedType).
WithDetail("actualType", actualType)
}
// NewDatasourceUnreachableError creates an error for unreachable datasource
func NewDatasourceUnreachableError(uid string, url string, cause error) *ValidationError {
return NewValidationError(
ErrCodeDatasourceUnreachable,
fmt.Sprintf("datasource %s at %s is unreachable", uid, url),
http.StatusServiceUnavailable,
).WithDetail("datasourceUID", uid).
WithDetail("url", url).
WithCause(cause)
}
// NewAPIUnavailableError creates an error for unavailable API
func NewAPIUnavailableError(statusCode int, responseBody string, cause error) *ValidationError {
return NewValidationError(
ErrCodeAPIUnavailable,
fmt.Sprintf("Prometheus API returned status %d", statusCode),
http.StatusBadGateway,
).WithDetail("upstreamStatus", statusCode).
WithDetail("responseBody", responseBody).
WithCause(cause)
}
// NewAPIInvalidResponseError creates an error for invalid API response
func NewAPIInvalidResponseError(message string, cause error) *ValidationError {
return NewValidationError(
ErrCodeAPIInvalidResponse,
fmt.Sprintf("Prometheus API returned invalid response: %s", message),
http.StatusBadGateway,
).WithCause(cause)
}
// NewAPITimeoutError creates an error for API timeout
func NewAPITimeoutError(url string, cause error) *ValidationError {
return NewValidationError(
ErrCodeAPITimeout,
fmt.Sprintf("request to %s timed out", url),
http.StatusGatewayTimeout,
).WithDetail("url", url).
WithCause(cause)
}
// NewDatasourceAuthError creates an error for authentication failures
func NewDatasourceAuthError(uid string, statusCode int) *ValidationError {
return NewValidationError(
ErrCodeDatasourceAuth,
fmt.Sprintf("authentication failed for datasource %s (status %d)", uid, statusCode),
http.StatusUnauthorized,
).WithDetail("datasourceUID", uid).
WithDetail("upstreamStatus", statusCode)
}
// IsValidationError checks if an error is a ValidationError
func IsValidationError(err error) bool {
var validationErr *ValidationError
return errors.As(err, &validationErr)
}
// GetValidationError extracts a ValidationError from an error chain
func GetValidationError(err error) *ValidationError {
var validationErr *ValidationError
if errors.As(err, &validationErr) {
return validationErr
}
return nil
}
// GetHTTPStatusCode returns the appropriate HTTP status code for an error
func GetHTTPStatusCode(err error) int {
if validationErr := GetValidationError(err); validationErr != nil {
return validationErr.StatusCode
}
return http.StatusInternalServerError
}
@@ -0,0 +1,142 @@
package prometheus
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"github.com/grafana/grafana/apps/dashvalidator/pkg/validator"
)
// Fetcher fetches available metrics from a Prometheus datasource
type Fetcher struct{}
// NewFetcher creates a new Prometheus metrics fetcher
func NewFetcher() *Fetcher {
return &Fetcher{}
}
// prometheusResponse represents the Prometheus API response structure
type prometheusResponse struct {
Status string `json:"status"`
Data []string `json:"data"`
Error string `json:"error,omitempty"`
}
// FetchMetrics queries Prometheus to get all available metric names
// It uses the /api/v1/label/__name__/values endpoint
// The provided HTTP client should have proper authentication configured
func (f *Fetcher) FetchMetrics(ctx context.Context, datasourceURL string, client *http.Client) ([]string, error) {
// Build the API URL
baseURL, err := url.Parse(datasourceURL)
if err != nil {
return nil, validator.NewValidationError(
validator.ErrCodeDatasourceConfig,
"invalid datasource URL",
http.StatusBadRequest,
).WithCause(err).WithDetail("url", datasourceURL)
}
// Append Prometheus API endpoint to base URL path using path.Join
// This correctly handles datasources with existing paths (e.g., /api/prom)
endpoint := "api/v1/label/__name__/values"
baseURL.Path = path.Join(baseURL.Path, endpoint)
// Create the request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL.String(), nil)
if err != nil {
return nil, validator.NewValidationError(
validator.ErrCodeInternal,
"failed to create HTTP request",
http.StatusInternalServerError,
).WithCause(err)
}
// Execute the request using the provided authenticated client
resp, err := client.Do(req)
if err != nil {
// Check if it's a timeout error
if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "timeout") {
return nil, validator.NewAPITimeoutError(baseURL.String(), err)
}
// Network or connection error - datasource is unreachable
return nil, validator.NewDatasourceUnreachableError("", datasourceURL, err)
}
defer func() { _ = resp.Body.Close() }()
// Read response body for error reporting
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
body = []byte("<unable to read response body>")
}
// Check HTTP status code
switch resp.StatusCode {
case http.StatusOK:
// Success - continue to parse response
case http.StatusUnauthorized, http.StatusForbidden:
// Authentication or authorization failure
return nil, validator.NewDatasourceAuthError("", resp.StatusCode).
WithDetail("url", baseURL.String()).
WithDetail("responseBody", string(body))
case http.StatusNotFound:
// Endpoint not found - might not be a valid Prometheus instance
return nil, validator.NewAPIUnavailableError(
resp.StatusCode,
string(body),
fmt.Errorf("endpoint not found - this may not be a valid Prometheus datasource"),
).WithDetail("url", baseURL.String())
case http.StatusTooManyRequests:
// Rate limiting
return nil, validator.NewValidationError(
validator.ErrCodeAPIRateLimit,
"Prometheus API rate limit exceeded",
http.StatusTooManyRequests,
).WithDetail("url", baseURL.String()).WithDetail("responseBody", string(body))
case http.StatusServiceUnavailable, http.StatusBadGateway, http.StatusGatewayTimeout:
// Upstream service is down or unavailable
return nil, validator.NewAPIUnavailableError(resp.StatusCode, string(body), nil).
WithDetail("url", baseURL.String())
default:
// Other error status codes
return nil, validator.NewAPIUnavailableError(resp.StatusCode, string(body), nil).
WithDetail("url", baseURL.String())
}
// Parse the response JSON
var promResp prometheusResponse
if err := json.Unmarshal(body, &promResp); err != nil {
return nil, validator.NewAPIInvalidResponseError(
"response is not valid JSON",
err,
).WithDetail("url", baseURL.String()).WithDetail("responseBody", string(body))
}
// Check Prometheus API status field
if promResp.Status != "success" {
errorMsg := promResp.Error
if errorMsg == "" {
errorMsg = "unknown error"
}
return nil, validator.NewAPIInvalidResponseError(
fmt.Sprintf("Prometheus API returned error status: %s", errorMsg),
nil,
).WithDetail("url", baseURL.String()).WithDetail("prometheusError", errorMsg)
}
// Validate that we got data
if promResp.Data == nil {
return nil, validator.NewAPIInvalidResponseError(
"response missing 'data' field",
nil,
).WithDetail("url", baseURL.String()).WithDetail("responseBody", string(body))
}
return promResp.Data, nil
}
@@ -0,0 +1,442 @@
package prometheus
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/grafana/grafana/apps/dashvalidator/pkg/validator"
"github.com/stretchr/testify/require"
)
// ============================================================================
// Category 1: Happy Path - Successful Metric Fetching
// ============================================================================
func TestFetchMetrics_Success_ReturnsMetrics(t *testing.T) {
// Setup test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/api/v1/label/__name__/values", r.URL.Path)
// Return valid response
resp := prometheusResponse{
Status: "success",
Data: []string{"up", "http_requests_total", "process_cpu_seconds_total"},
}
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
}))
defer server.Close()
// Execute
fetcher := NewFetcher()
metrics, err := fetcher.FetchMetrics(context.Background(), server.URL, server.Client())
// Verify
require.NoError(t, err)
require.Len(t, metrics, 3)
require.ElementsMatch(t, []string{"up", "http_requests_total", "process_cpu_seconds_total"}, metrics)
}
func TestFetchMetrics_Success_URLWithPath(t *testing.T) {
// Test that URLs with existing paths (e.g., /api/prom) work correctly
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify the path is correctly joined
require.Equal(t, "/api/prom/api/v1/label/__name__/values", r.URL.Path)
resp := prometheusResponse{
Status: "success",
Data: []string{"metric_a", "metric_b"},
}
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
}))
defer server.Close()
// Execute with path suffix
fetcher := NewFetcher()
metrics, err := fetcher.FetchMetrics(context.Background(), server.URL+"/api/prom", server.Client())
// Verify
require.NoError(t, err)
require.Len(t, metrics, 2)
require.ElementsMatch(t, []string{"metric_a", "metric_b"}, metrics)
}
// ============================================================================
// Category 2: URL Parsing Errors
// ============================================================================
func TestFetchMetrics_InvalidURL_ReturnsConfigError(t *testing.T) {
tests := []struct {
name string
url string
expectedMsg string
}{
{
name: "malformed URL with control character",
url: "http://example.com/\x00path",
expectedMsg: "invalid datasource URL",
},
{
name: "invalid URL scheme",
url: "://missing-scheme",
expectedMsg: "invalid datasource URL",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fetcher := NewFetcher()
metrics, err := fetcher.FetchMetrics(context.Background(), tt.url, &http.Client{})
// Verify error
require.Error(t, err)
require.Nil(t, metrics)
require.True(t, validator.IsValidationError(err))
validationErr := validator.GetValidationError(err)
require.Equal(t, validator.ErrCodeDatasourceConfig, validationErr.Code)
require.Equal(t, http.StatusBadRequest, validator.GetHTTPStatusCode(err))
require.Contains(t, validationErr.Message, tt.expectedMsg)
})
}
}
func TestFetchMetrics_EmptyURL_ReturnsNetworkError(t *testing.T) {
// Note: An empty URL is technically parseable by Go's url.Parse
// but results in a network error when trying to make the request
fetcher := NewFetcher()
metrics, err := fetcher.FetchMetrics(context.Background(), "", &http.Client{})
// Verify error - empty URL fails at network level, not URL parsing
require.Error(t, err)
require.Nil(t, metrics)
require.True(t, validator.IsValidationError(err))
validationErr := validator.GetValidationError(err)
require.Equal(t, validator.ErrCodeDatasourceUnreachable, validationErr.Code)
}
// ============================================================================
// Category 3: Network and Connection Errors
// ============================================================================
func TestFetchMetrics_ConnectionRefused_ReturnsUnreachableError(t *testing.T) {
// Use a port that's definitely not listening
fetcher := NewFetcher()
client := &http.Client{Timeout: 100 * time.Millisecond}
metrics, err := fetcher.FetchMetrics(context.Background(), "http://127.0.0.1:1", client)
// Verify error
require.Error(t, err)
require.Nil(t, metrics)
require.True(t, validator.IsValidationError(err))
validationErr := validator.GetValidationError(err)
require.Equal(t, validator.ErrCodeDatasourceUnreachable, validationErr.Code)
require.Equal(t, http.StatusServiceUnavailable, validator.GetHTTPStatusCode(err))
}
func TestFetchMetrics_ContextCancelled_ReturnsError(t *testing.T) {
// Create a server that delays response
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Wait longer than we'll allow
time.Sleep(200 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Create context and cancel immediately
ctx, cancel := context.WithCancel(context.Background())
cancel()
fetcher := NewFetcher()
metrics, err := fetcher.FetchMetrics(ctx, server.URL, server.Client())
// Verify error - context cancellation returns unreachable error
require.Error(t, err)
require.Nil(t, metrics)
}
// ============================================================================
// Category 4: Timeout Errors
// ============================================================================
func TestFetchMetrics_HTTPClientTimeout_ReturnsTimeoutError(t *testing.T) {
// This test verifies that the HTTP client-level timeout works correctly.
// Unlike context deadline, this tests the http.Client.Timeout field.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Wait longer than the client timeout
time.Sleep(100 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Create HTTP client with a very short timeout
client := &http.Client{
Timeout: 10 * time.Millisecond,
}
fetcher := NewFetcher()
metrics, err := fetcher.FetchMetrics(context.Background(), server.URL, client)
// Verify timeout error is returned
require.Error(t, err)
require.Nil(t, metrics)
require.True(t, validator.IsValidationError(err))
validationErr := validator.GetValidationError(err)
// HTTP client timeout returns a timeout error which is detected as ErrCodeAPITimeout
require.Equal(t, validator.ErrCodeAPITimeout, validationErr.Code)
require.Equal(t, http.StatusGatewayTimeout, validator.GetHTTPStatusCode(err))
}
func TestFetchMetrics_DeadlineExceeded_ReturnsTimeoutError(t *testing.T) {
// Create a server that delays response longer than the context deadline
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Wait longer than the deadline
time.Sleep(100 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Create context with very short deadline
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
fetcher := NewFetcher()
metrics, err := fetcher.FetchMetrics(ctx, server.URL, server.Client())
// Verify error
require.Error(t, err)
require.Nil(t, metrics)
require.True(t, validator.IsValidationError(err))
validationErr := validator.GetValidationError(err)
require.Equal(t, validator.ErrCodeAPITimeout, validationErr.Code)
require.Equal(t, http.StatusGatewayTimeout, validator.GetHTTPStatusCode(err))
require.Contains(t, validationErr.Message, "timed out")
}
// ============================================================================
// Category 5: HTTP Status Code Handling
// ============================================================================
func TestFetchMetrics_HTTPStatusCodes_ReturnsExpectedError(t *testing.T) {
tests := []struct {
name string
statusCode int
expectedErrorCode validator.ErrorCode
expectedHTTPStatus int
expectedMsgPart string
}{
{
name: "401 Unauthorized",
statusCode: http.StatusUnauthorized,
expectedErrorCode: validator.ErrCodeDatasourceAuth,
expectedHTTPStatus: http.StatusUnauthorized,
expectedMsgPart: "authentication failed",
},
{
name: "403 Forbidden",
statusCode: http.StatusForbidden,
expectedErrorCode: validator.ErrCodeDatasourceAuth,
expectedHTTPStatus: http.StatusUnauthorized,
expectedMsgPart: "authentication failed",
},
{
name: "404 Not Found",
statusCode: http.StatusNotFound,
expectedErrorCode: validator.ErrCodeAPIUnavailable,
expectedHTTPStatus: http.StatusBadGateway,
expectedMsgPart: "status 404",
},
{
name: "429 Rate Limit",
statusCode: http.StatusTooManyRequests,
expectedErrorCode: validator.ErrCodeAPIRateLimit,
expectedHTTPStatus: http.StatusTooManyRequests,
expectedMsgPart: "rate limit",
},
{
name: "500 Internal Server Error",
statusCode: http.StatusInternalServerError,
expectedErrorCode: validator.ErrCodeAPIUnavailable,
expectedHTTPStatus: http.StatusBadGateway,
expectedMsgPart: "status 500",
},
{
name: "502 Bad Gateway",
statusCode: http.StatusBadGateway,
expectedErrorCode: validator.ErrCodeAPIUnavailable,
expectedHTTPStatus: http.StatusBadGateway,
expectedMsgPart: "status 502",
},
{
name: "503 Service Unavailable",
statusCode: http.StatusServiceUnavailable,
expectedErrorCode: validator.ErrCodeAPIUnavailable,
expectedHTTPStatus: http.StatusBadGateway,
expectedMsgPart: "status 503",
},
{
name: "504 Gateway Timeout",
statusCode: http.StatusGatewayTimeout,
expectedErrorCode: validator.ErrCodeAPIUnavailable,
expectedHTTPStatus: http.StatusBadGateway,
expectedMsgPart: "status 504",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.statusCode)
_, _ = w.Write([]byte("error response body"))
}))
defer server.Close()
fetcher := NewFetcher()
metrics, err := fetcher.FetchMetrics(context.Background(), server.URL, server.Client())
// Verify error
require.Error(t, err)
require.Nil(t, metrics)
require.True(t, validator.IsValidationError(err))
validationErr := validator.GetValidationError(err)
require.Equal(t, tt.expectedErrorCode, validationErr.Code)
require.Equal(t, tt.expectedHTTPStatus, validator.GetHTTPStatusCode(err))
require.Contains(t, validationErr.Message, tt.expectedMsgPart)
})
}
}
// ============================================================================
// Category 6: JSON Response Validation
// ============================================================================
func TestFetchMetrics_InvalidJSON_ReturnsParsingError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("not valid json"))
}))
defer server.Close()
fetcher := NewFetcher()
metrics, err := fetcher.FetchMetrics(context.Background(), server.URL, server.Client())
// Verify error
require.Error(t, err)
require.Nil(t, metrics)
require.True(t, validator.IsValidationError(err))
validationErr := validator.GetValidationError(err)
require.Equal(t, validator.ErrCodeAPIInvalidResponse, validationErr.Code)
require.Equal(t, http.StatusBadGateway, validator.GetHTTPStatusCode(err))
require.Contains(t, validationErr.Message, "not valid JSON")
}
func TestFetchMetrics_StatusError_ReturnsInvalidResponseError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := prometheusResponse{
Status: "error",
Error: "execution error: something went wrong",
}
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
}))
defer server.Close()
fetcher := NewFetcher()
metrics, err := fetcher.FetchMetrics(context.Background(), server.URL, server.Client())
// Verify error
require.Error(t, err)
require.Nil(t, metrics)
require.True(t, validator.IsValidationError(err))
validationErr := validator.GetValidationError(err)
require.Equal(t, validator.ErrCodeAPIInvalidResponse, validationErr.Code)
require.Equal(t, http.StatusBadGateway, validator.GetHTTPStatusCode(err))
require.Contains(t, validationErr.Message, "error status")
require.Contains(t, validationErr.Message, "execution error: something went wrong")
require.Equal(t, "execution error: something went wrong", validationErr.Details["prometheusError"])
}
func TestFetchMetrics_MissingDataField_ReturnsInvalidResponseError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Return JSON with success status but nil data
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"success"}`))
}))
defer server.Close()
fetcher := NewFetcher()
metrics, err := fetcher.FetchMetrics(context.Background(), server.URL, server.Client())
// Verify error
require.Error(t, err)
require.Nil(t, metrics)
require.True(t, validator.IsValidationError(err))
validationErr := validator.GetValidationError(err)
require.Equal(t, validator.ErrCodeAPIInvalidResponse, validationErr.Code)
require.Equal(t, http.StatusBadGateway, validator.GetHTTPStatusCode(err))
require.Contains(t, validationErr.Message, "missing 'data' field")
}
func TestFetchMetrics_ExtraFields_Succeeds(t *testing.T) {
// Test forward compatibility - extra fields should be ignored
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Return response with extra fields
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{
"status": "success",
"data": ["metric_a", "metric_b"],
"warnings": ["some warning"],
"extraField": "should be ignored",
"version": "2.0"
}`))
}))
defer server.Close()
fetcher := NewFetcher()
metrics, err := fetcher.FetchMetrics(context.Background(), server.URL, server.Client())
// Verify success despite extra fields
require.NoError(t, err)
require.Len(t, metrics, 2)
require.ElementsMatch(t, []string{"metric_a", "metric_b"}, metrics)
}
func TestFetchMetrics_EmptyDataArray_Succeeds(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := prometheusResponse{
Status: "success",
Data: []string{}, // Empty but present
}
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
}))
defer server.Close()
fetcher := NewFetcher()
metrics, err := fetcher.FetchMetrics(context.Background(), server.URL, server.Client())
// Verify success with empty data
require.NoError(t, err)
require.NotNil(t, metrics)
require.Empty(t, metrics)
}
@@ -0,0 +1,49 @@
package prometheus
import (
"fmt"
"github.com/prometheus/prometheus/promql/parser"
)
// Parser extracts metric names from PromQL queries
type Parser struct{}
// NewParser creates a new PromQL parser
func NewParser() *Parser {
return &Parser{}
}
// ExtractMetrics parses a PromQL query and extracts all metric names
// For example: "rate(http_requests_total[5m])" returns ["http_requests_total"]
func (p *Parser) ExtractMetrics(query string) ([]string, error) {
// Parse the PromQL expression
expr, err := parser.ParseExpr(query)
if err != nil {
return nil, fmt.Errorf("failed to parse PromQL query: %w", err)
}
// Extract metric names by walking the AST
metrics := make(map[string]bool) // Use map to deduplicate
parser.Inspect(expr, func(node parser.Node, _ []parser.Node) error {
// VectorSelector represents a metric selector like "up" or "up{job="foo"}"
if vs, ok := node.(*parser.VectorSelector); ok {
metrics[vs.Name] = true
}
// MatrixSelector represents range queries like "up[5m]"
if ms, ok := node.(*parser.MatrixSelector); ok {
if vs, ok := ms.VectorSelector.(*parser.VectorSelector); ok {
metrics[vs.Name] = true
}
}
return nil
})
// Convert map to slice
result := make([]string, 0, len(metrics))
for metric := range metrics {
result = append(result, metric)
}
return result, nil
}
@@ -0,0 +1,137 @@
package prometheus
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestExtractMetrics(t *testing.T) {
parser := NewParser()
tests := []struct {
name string
query string
expected []string
expectError bool
errorContains string
}{
// Category 1: Basic Extraction (3 tests - covers AST node types)
{
name: "simple metric",
query: "up",
expected: []string{"up"},
},
{
name: "metric with labels",
query: `up{job="api"}`,
expected: []string{"up"},
},
{
name: "range selector",
query: "up[5m]",
expected: []string{"up"},
},
// Category 2: Function Composition (2 tests - nested complexity)
{
name: "single function",
query: "rate(http_requests_total[5m])",
expected: []string{"http_requests_total"},
},
{
name: "nested functions",
query: "sum(rate(requests[5m]))",
expected: []string{"requests"},
},
// Category 3: Binary Operations (2 tests - multiple metrics)
{
name: "two metrics",
query: "metric_a + metric_b",
expected: []string{"metric_a", "metric_b"},
},
{
name: "three metrics nested",
query: "(a + b) / c",
expected: []string{"a", "b", "c"},
},
// Category 4: Deduplication (1 test - critical behavior)
{
name: "duplicate metric",
query: "up + up",
expected: []string{"up"},
},
// Category 5: Edge Cases (2 tests - boundary behaviors)
{
name: "no metrics (literals only)",
query: "1 + 1",
expected: []string{},
},
{
name: "built-in function without metric",
query: "time()",
expected: []string{},
},
{
name: "comparison operator",
query: "a > 5",
expected: []string{"a"},
},
// Category 6: Real Dashboard Patterns (3 tests - production queries)
{
name: "binary op with function and labels",
query: `(time() - process_start_time_seconds{job="prometheus", instance=~"$node"})`,
expected: []string{"process_start_time_seconds"},
},
{
name: "rate with regex label matcher",
query: `rate(prometheus_local_storage_ingested_samples_total{instance=~"$node"}[5m])`,
expected: []string{"prometheus_local_storage_ingested_samples_total"},
},
{
name: "metric with negation and multiple labels",
query: `prometheus_target_interval_length_seconds{quantile!="0.01", quantile!="0.05", instance=~"$node"}`,
expected: []string{"prometheus_target_interval_length_seconds"},
},
// Category 7: Error Handling (2 tests - validation)
{
name: "empty string",
query: "",
expectError: true,
errorContains: "parse",
},
{
name: "malformed expression",
query: "{{invalid}}",
expectError: true,
errorContains: "parse",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parser.ExtractMetrics(tt.query)
// Check error expectation
if tt.expectError {
require.Error(t, err, "Expected error for query: %q", tt.query)
if tt.errorContains != "" {
require.ErrorContains(t, err, tt.errorContains,
"Error should contain %q for query: %q", tt.errorContains, tt.query)
}
return
}
require.NoError(t, err, "Unexpected error for query: %q", tt.query)
// Check result matches expected (order-independent for multiple metrics)
require.ElementsMatch(t, tt.expected, result,
"ExtractMetrics(%q) returned unexpected metrics", tt.query)
})
}
}
@@ -0,0 +1,40 @@
package prometheus
import (
"context"
"net/http"
"time"
"github.com/grafana/grafana/apps/dashvalidator/pkg/cache"
"github.com/grafana/grafana/apps/dashvalidator/pkg/validator"
)
// PrometheusProvider implements cache.MetricsProvider for Prometheus datasources.
// It wraps the existing Fetcher and returns results with a configurable TTL.
type PrometheusProvider struct {
fetcher validator.MetricsFetcher
ttl time.Duration
}
// NewPrometheusProvider creates a new PrometheusProvider with the given TTL.
func NewPrometheusProvider(ttl time.Duration) *PrometheusProvider {
return &PrometheusProvider{
fetcher: NewFetcher(),
ttl: ttl,
}
}
// GetMetrics implements cache.MetricsProvider.
// It fetches available metrics from Prometheus and returns them with the provider's TTL.
func (p *PrometheusProvider) GetMetrics(ctx context.Context, datasourceUID, datasourceURL string,
client *http.Client) (*cache.MetricsResult, error) {
metrics, err := p.fetcher.FetchMetrics(ctx, datasourceURL, client)
if err != nil {
return nil, err
}
return &cache.MetricsResult{
Metrics: metrics,
TTL: p.ttl,
}, nil
}
@@ -0,0 +1,201 @@
package prometheus
import (
"context"
"fmt"
"github.com/grafana/grafana/apps/dashvalidator/pkg/cache"
"github.com/grafana/grafana/apps/dashvalidator/pkg/validator"
"github.com/grafana/grafana/pkg/services/datasources"
)
// Validator implements validator.DatasourceValidator for Prometheus datasources
type Validator struct {
parser validator.MetricExtractor
cache *cache.MetricsCache
}
// evaluate at compile time that Validator implements DatasourceValidator interface
var _ validator.DatasourceValidator = (*Validator)(nil)
// NewValidator creates a new Prometheus validator.
// The metricsCache parameter is required - pass nil will cause a panic.
func NewValidator(mc *cache.MetricsCache) *Validator {
if mc == nil {
panic("metricsCache cannot be nil")
}
return &Validator{
parser: NewParser(),
cache: mc,
}
}
// ValidateQueries validates Prometheus queries against the datasource.
// It orchestrates parsing, fetching, compatibility checking, and scoring.
func (v *Validator) ValidateQueries(ctx context.Context, queries []validator.Query, datasource validator.Datasource) (*validator.ValidationResult, error) {
parseResults, uniqueMetrics, checkedCount := parseQueries(queries, v.parser)
availableSet, err := fetchAvailableMetrics(ctx, v.cache, datasource)
if err != nil {
return nil, err
}
foundCount, missingMetrics, missingSet := calculateCompatibility(uniqueMetrics, availableSet)
return &validator.ValidationResult{
TotalQueries: len(queries),
CheckedQueries: checkedCount,
QueryBreakdown: buildQueryBreakdown(queries, parseResults, missingSet),
CompatibilityResult: validator.CompatibilityResult{
TotalMetrics: len(uniqueMetrics),
FoundMetrics: foundCount,
MissingMetrics: missingMetrics,
CompatibilityScore: calculateOverallScore(len(queries), checkedCount, len(uniqueMetrics), foundCount),
},
}, nil
}
// fetchAvailableMetrics retrieves available metrics from the datasource via cache
// and returns them as a set for O(1) lookup.
// This is a thin wrapper that delegates to the cache layer.
func fetchAvailableMetrics(ctx context.Context, metricsCache *cache.MetricsCache, datasource validator.Datasource) (map[string]bool, error) {
availableMetrics, err := metricsCache.GetMetrics(ctx, datasources.DS_PROMETHEUS, datasource.UID, datasource.URL, datasource.HTTPClient)
if err != nil {
return nil, fmt.Errorf("failed to fetch metrics from Prometheus: %w", err)
}
availableSet := make(map[string]bool, len(availableMetrics))
for _, metric := range availableMetrics {
availableSet[metric] = true
}
return availableSet, nil
}
// parseQueries parses all queries to extract metrics.
// Returns per-query parse results, a deduplicated list of all metrics, and
// the count of successfully parsed queries.
func parseQueries(queries []validator.Query, parser validator.MetricExtractor) ([]parseQueryResult, []string, int) {
parseResults := make([]parseQueryResult, len(queries))
allMetrics := make(map[string]bool)
checkedCount := 0
for i, query := range queries {
metrics, err := parser.ExtractMetrics(query.QueryText)
parseResults[i] = parseQueryResult{
metrics: metrics,
parseError: err,
}
if err == nil {
checkedCount++
for _, metric := range metrics {
allMetrics[metric] = true
}
}
}
uniqueMetrics := make([]string, 0, len(allMetrics))
for metric := range allMetrics {
uniqueMetrics = append(uniqueMetrics, metric)
}
return parseResults, uniqueMetrics, checkedCount
}
// parseQueryResult holds the outcome of parsing a single query.
type parseQueryResult struct {
metrics []string
parseError error
}
// buildQueryBreakdown creates per-query validation results.
// Queries that failed to parse get 0% score but are still included in the breakdown.
// Queries with no metrics (e.g., time()) get 100% score.
func buildQueryBreakdown(queries []validator.Query, parseResults []parseQueryResult, missingSet map[string]bool) []validator.QueryResult {
breakdown := make([]validator.QueryResult, 0, len(queries))
for i, query := range queries {
parseResult := parseResults[i]
queryResult := validator.QueryResult{
PanelTitle: query.PanelTitle,
PanelID: query.PanelID,
QueryRefID: query.RefID,
}
if parseResult.parseError != nil {
errMsg := parseResult.parseError.Error()
queryResult.ParseError = &errMsg
queryResult.TotalMetrics = 0
queryResult.FoundMetrics = 0
queryResult.MissingMetrics = []string{}
queryResult.CompatibilityScore = 0.0
breakdown = append(breakdown, queryResult)
continue
}
metrics := parseResult.metrics
queryResult.TotalMetrics = len(metrics)
queryMissing := make([]string, 0)
for _, metric := range metrics {
if missingSet[metric] {
queryMissing = append(queryMissing, metric)
}
}
queryResult.MissingMetrics = queryMissing
queryResult.FoundMetrics = queryResult.TotalMetrics - len(queryMissing)
if queryResult.TotalMetrics > 0 {
queryResult.CompatibilityScore = float64(queryResult.FoundMetrics) / float64(queryResult.TotalMetrics)
} else {
queryResult.CompatibilityScore = 1.0
}
breakdown = append(breakdown, queryResult)
}
return breakdown
}
// calculateCompatibility checks which metrics are available and which are missing.
// Returns the count of found metrics, a slice of missing metric names (for JSON),
// and a set of missing metrics (for O(1) lookup in query breakdown).
func calculateCompatibility(uniqueMetrics []string, availableSet map[string]bool) (foundCount int, missingMetrics []string, missingSet map[string]bool) {
missingSet = make(map[string]bool)
for _, metric := range uniqueMetrics {
if !availableSet[metric] {
missingSet[metric] = true
}
}
foundCount = len(uniqueMetrics) - len(missingSet)
missingMetrics = make([]string, 0, len(missingSet))
for metric := range missingSet {
missingMetrics = append(missingMetrics, metric)
}
return foundCount, missingMetrics, missingSet
}
// calculateOverallScore returns a compatibility score between 0.0 and 1.0.
// When metrics exist, it returns foundMetrics/totalMetrics.
// When no metrics were extracted, it uses totalQueries and checkedQueries
// to distinguish "nothing to validate" (1.0) from "everything broke" (0.0).
func calculateOverallScore(totalQueries, checkedQueries, totalMetrics, foundMetrics int) float64 {
if totalMetrics > 0 {
return float64(foundMetrics) / float64(totalMetrics)
}
// No metrics to check — distinguish why:
if totalQueries == 0 {
return 1.0 // Empty dashboard, nothing can break
}
if checkedQueries > 0 {
return 1.0 // Valid queries like time() or 1+1 that reference no metrics
}
return 0.0 // Every query failed to parse, can't verify compatibility
}
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
package validator
import (
"context"
"net/http"
)
// DatasourceValidator validates dashboard queries against a datasource.
// Implementations exist per datasource type (Prometheus, MySQL, etc.).
type DatasourceValidator interface {
// ValidateQueries checks if queries are compatible with the datasource
ValidateQueries(ctx context.Context, queries []Query, datasource Datasource) (*ValidationResult, error)
}
// MetricExtractor parses datasource-specific queries and extracts metric/entity names.
// Implementations exist per datasource type (Prometheus, MySQL, Loki, etc.).
type MetricExtractor interface {
// ExtractMetrics parses a query and returns the list of metrics/entities referenced.
// Returns an error if the query syntax is invalid.
ExtractMetrics(queryText string) ([]string, error)
}
// MetricsFetcher fetches available metrics/entities from a datasource.
type MetricsFetcher interface {
// FetchMetrics queries the datasource to get all available metric/entity names.
// The provided HTTP client should have proper authentication configured.
FetchMetrics(ctx context.Context, datasourceURL string, client *http.Client) ([]string, error)
}
// Query represents a dashboard query to validate
type Query struct {
RefID string // Query reference ID (A, B, C, etc.)
QueryText string // The actual query text (PromQL, SQL, etc.)
PanelTitle string // Panel title for user-friendly reporting
PanelID int // Panel ID for reference
}
// Datasource contains connection information for a datasource
type Datasource struct {
UID string // Datasource UID from dashboard
Type string // Datasource type (prometheus, mysql, etc.)
Name string // Datasource name for reporting
URL string // Datasource URL for API calls
HTTPClient *http.Client // Authenticated HTTP client for making requests
}
// CompatibilityResult contains the shared metrics compatibility fields
// used by both ValidationResult (aggregate) and QueryResult (per-query).
type CompatibilityResult struct {
TotalMetrics int `json:"totalMetrics"`
FoundMetrics int `json:"foundMetrics"`
MissingMetrics []string `json:"missingMetrics"`
CompatibilityScore float64 `json:"compatibilityScore"`
}
// ValidationResult contains validation results for a datasource
type ValidationResult struct {
TotalQueries int `json:"totalQueries"` // Total number of queries found
CheckedQueries int `json:"checkedQueries"` // Number of queries successfully checked
QueryBreakdown []QueryResult `json:"queryBreakdown"` // Per-query results
CompatibilityResult
}
// QueryResult contains validation results for a single query
type QueryResult struct {
PanelTitle string `json:"panelTitle"` // Panel title
PanelID int `json:"panelID"` // Panel ID
QueryRefID string `json:"queryRefId"` // Query reference ID
CompatibilityResult
ParseError *string `json:"parseError,omitempty"` // Optional parse error message (nil = parsed successfully)
}
@@ -0,0 +1,164 @@
package validator
import (
"testing"
"github.com/stretchr/testify/require"
)
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)
require.Equal(t, tt.expected, result, "isVariableReference(%q) returned unexpected result", tt.input)
})
}
}
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)
require.Equal(t, tt.expected, result, "extractVariableName(%q) returned unexpected result", tt.input)
})
}
}
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)
require.Equal(t, tt.expected, result, "isPrometheusVariable(%q, dashboard) returned unexpected result", tt.varRef)
})
}
}
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)
require.Equal(t, tt.expectedUID, result, "resolveDatasourceUID(%q, %q, dashboard): %s", tt.uid, singleUID, tt.description)
})
}
}
@@ -79,6 +79,9 @@ export interface QueryBreakdown {
// Calculated as: (foundMetrics / totalMetrics) * 100
// 100 = query will work perfectly, 0 = query will return no data.
compatibilityScore: number;
// Optional error message for queries that failed to parse.
// When present, the query is treated as 0% compatible.
parseError?: string;
}
export const defaultQueryBreakdown = (): QueryBreakdown => ({
+67 -10
View File
@@ -1,6 +1,8 @@
package dashvalidator
import (
"context"
"github.com/grafana/grafana-app-sdk/app"
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
"github.com/grafana/grafana-app-sdk/simple"
@@ -9,26 +11,49 @@ import (
validatorapis "github.com/grafana/grafana/apps/dashvalidator/pkg/apis/manifestdata"
validatorapp "github.com/grafana/grafana/apps/dashvalidator/pkg/app"
roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/apps/dashvalidator/pkg/cache"
"github.com/grafana/grafana/apps/dashvalidator/pkg/validator"
"github.com/grafana/grafana/apps/dashvalidator/pkg/validator/prometheus"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
)
var _ appsdkapiserver.AppInstaller = (*DashValidatorAppInstaller)(nil)
type DashValidatorAppInstaller struct {
appsdkapiserver.AppInstaller
ac accesscontrol.AccessControl
}
// RegisterAppInstaller is called by Wire to create the app installer
// RegisterAppInstaller is called by Wire to create the app installer.
// This is the composition root where all components are created and wired together.
func RegisterAppInstaller(
datasourceSvc datasources.DataSourceService,
pluginCtx *plugincontext.Provider,
httpClientProvider httpclient.Provider,
ac accesscontrol.AccessControl,
) (*DashValidatorAppInstaller, error) {
// Create specific config for the app
// Create MetricsCache - shared cache for all datasource types
metricsCache := cache.NewMetricsCache()
// Create and register Prometheus provider
prometheusProvider := prometheus.NewPrometheusProvider(cache.DefaultMetricsCacheTTL)
metricsCache.RegisterProvider(datasources.DS_PROMETHEUS, prometheusProvider)
// Create validators map - keyed by datasource type
validators := map[string]validator.DatasourceValidator{
datasources.DS_PROMETHEUS: prometheus.NewValidator(metricsCache),
}
// Create specific config for the app with all components
specificConfig := &validatorapp.DashValidatorConfig{
DatasourceSvc: datasourceSvc,
PluginCtx: pluginCtx,
DatasourceSvc: datasourceSvc,
HTTPClientProvider: httpClientProvider,
MetricsCache: metricsCache,
Validators: validators,
AC: ac,
}
// Create the app provider
@@ -57,11 +82,43 @@ func RegisterAppInstaller(
return &DashValidatorAppInstaller{
AppInstaller: defaultInstaller,
ac: ac,
}, nil
}
// GetAuthorizer provides the authorization for the app
// GetAuthorizer provides fine-grained authorization for the app.
// Uses AccessControl to evaluate permissions (datasources:read, datasources:query, + dashboards:create)
func (a *DashValidatorAppInstaller) GetAuthorizer() authorizer.Authorizer {
//nolint:staticcheck
return roleauthorizer.NewRoleAuthorizer()
return authorizer.AuthorizerFunc(
func(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) {
user, err := identity.GetRequester(ctx)
if err != nil {
return authorizer.DecisionDeny, "authentication required", err
}
// For now we only support /check, which is a POST and we don't support any other verbs
if attr.GetVerb() != "create" {
return authorizer.DecisionDeny, "operation not supported", nil
}
// POST /check maps to "create" verb.
// No scope is defined because we don't know which datasources the user needs
// before the validation request is processed. This checks that the user has
// access to at least one datasource. Per-datasource scoped checks are applied
// when we resolve datasource UIDs in the handler.
evaluator := accesscontrol.EvalAll(
accesscontrol.EvalPermission(datasources.ActionRead),
accesscontrol.EvalPermission(datasources.ActionQuery),
accesscontrol.EvalPermission(dashboards.ActionDashboardsCreate),
)
ok, err := a.ac.Evaluate(ctx, user, evaluator)
if err != nil {
return authorizer.DecisionDeny, "permission check failed", err
}
if ok {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "insufficient permissions: datasources:read, datasources:query, and dashboards:create required", nil
},
)
}
@@ -0,0 +1,203 @@
package dashvalidator
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apiserver/pkg/authorization/authorizer"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
)
// mockAttributes implements authorizer.Attributes for testing.
type mockAttributes struct {
authorizer.Attributes
verb string
}
func (m *mockAttributes) GetVerb() string {
return m.verb
}
func TestGetAuthorizer(t *testing.T) {
// Use real AccessControl evaluator — this tests actual permission evaluation
// against the user's permission map, not mocked behavior.
ac := acimpl.ProvideAccessControl(nil)
tests := []authorizerTestCase{
{
name: "unauthenticated user cannot create",
ctx: context.TODO(),
attr: &mockAttributes{verb: "create"},
expectedDecision: authorizer.DecisionDeny,
expectedReason: "authentication required",
expectErr: true,
},
{
name: "admin with datasources:read + datasources:query + dashboards:create + \"create\" → Allow",
ctx: identity.WithRequester(
context.TODO(),
&identity.StaticRequester{
OrgRole: identity.RoleAdmin,
UserID: 1,
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {
datasources.ActionRead: {"datasources:*"},
datasources.ActionQuery: {"datasources:*"},
dashboards.ActionDashboardsCreate: {"dashboards:*"},
},
},
},
),
attr: &mockAttributes{verb: "create"},
expectedDecision: authorizer.DecisionAllow,
expectedReason: "",
expectErr: false,
},
{
name: "editor with datasources:read + datasources:query + dashboards:create + \"create\" → Allow",
ctx: identity.WithRequester(
context.TODO(),
&identity.StaticRequester{
OrgRole: identity.RoleEditor,
UserID: 1,
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {
datasources.ActionRead: {"datasources:*"},
datasources.ActionQuery: {"datasources:*"},
dashboards.ActionDashboardsCreate: {"dashboards:*"},
},
},
},
),
attr: &mockAttributes{verb: "create"},
expectedDecision: authorizer.DecisionAllow,
expectErr: false,
},
{
name: "viewer with datasources:read + dashboards:create + \"create\" → Deny",
ctx: identity.WithRequester(
context.TODO(),
&identity.StaticRequester{
OrgRole: identity.RoleViewer,
UserID: 1,
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {
datasources.ActionRead: {"datasources:*"},
dashboards.ActionDashboardsCreate: {"dashboards:*"},
},
},
},
),
attr: &mockAttributes{verb: "create"},
expectedDecision: authorizer.DecisionDeny,
expectedReason: "insufficient permissions",
expectErr: false,
},
{
name: "custom role (RoleNone) with datasources:read + datasources:query + dashboards:create + \"create\" → Allow",
ctx: identity.WithRequester(
context.TODO(),
&identity.StaticRequester{
OrgRole: identity.RoleNone,
UserID: 1,
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {
datasources.ActionRead: {"datasources:*"},
datasources.ActionQuery: {"datasources:*"},
dashboards.ActionDashboardsCreate: {"dashboards:*"},
},
},
},
),
attr: &mockAttributes{verb: "create"},
expectedDecision: authorizer.DecisionAllow,
expectErr: false,
},
{
name: "custom role (RoleNone) only with dashboards:create + \"create\" → Deny",
ctx: identity.WithRequester(
context.TODO(),
&identity.StaticRequester{
OrgRole: identity.RoleNone,
UserID: 1,
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {
dashboards.ActionDashboardsCreate: {"dashboards:*"},
},
},
},
),
attr: &mockAttributes{verb: "create"},
expectedDecision: authorizer.DecisionDeny,
expectedReason: "insufficient permissions",
expectErr: false,
},
{
name: "editor with all datasources:read + datasources:query + dashboards:create + \"delete\" → Deny",
ctx: identity.WithRequester(
context.TODO(),
&identity.StaticRequester{
OrgRole: identity.RoleEditor,
UserID: 1,
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {
datasources.ActionRead: {"datasources:*"},
datasources.ActionQuery: {"datasources:*"},
dashboards.ActionDashboardsCreate: {"dashboards:*"},
},
},
},
),
attr: &mockAttributes{verb: "delete"},
expectedDecision: authorizer.DecisionDeny,
expectedReason: "operation not supported",
expectErr: false,
},
}
runAuthorizerTests(t, ac, tests)
}
// runAuthorizerTests runs table-driven authorizer tests.
func runAuthorizerTests(t *testing.T, ac *acimpl.AccessControl, tests []authorizerTestCase) {
t.Helper()
installer := &DashValidatorAppInstaller{ac: ac}
authz := installer.GetAuthorizer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
decision, reason, err := authz.Authorize(tt.ctx, tt.attr)
assert.Equal(t, tt.expectedDecision, decision, "unexpected decision")
if tt.expectedReason != "" {
assert.Contains(t, reason, tt.expectedReason)
}
if tt.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
type authorizerTestCase struct {
name string
ctx context.Context
attr authorizer.Attributes
expectedDecision authorizer.Decision
expectedReason string
expectErr bool
}
+2 -2
View File
@@ -850,7 +850,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
dashValidatorAppInstaller, err := dashvalidator.RegisterAppInstaller(service15, plugincontextProvider)
dashValidatorAppInstaller, err := dashvalidator.RegisterAppInstaller(service15, httpclientProvider, accessControl)
if err != nil {
return nil, err
}
@@ -1541,7 +1541,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
dashValidatorAppInstaller, err := dashvalidator.RegisterAppInstaller(service15, plugincontextProvider)
dashValidatorAppInstaller, err := dashvalidator.RegisterAppInstaller(service15, httpclientProvider, accessControl)
if err != nil {
return nil, err
}
@@ -1,34 +1,61 @@
import { screen, waitFor } from '@testing-library/react';
import React from 'react';
import { render } from 'test/test-utils';
import { render, testWithFeatureToggles } from 'test/test-utils';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { CommunityDashboardSection } from './CommunityDashboardSection';
import { checkDashboardCompatibility, CompatibilityCheckResult } from './api/compatibilityApi';
import { fetchCommunityDashboards } from './api/dashboardLibraryApi';
import { DashboardLibraryInteractions } from './interactions';
import { GnetDashboard } from './types';
import { onUseCommunityDashboard } from './utils/communityDashboardHelpers';
import { onUseCommunityDashboard, interpolateDashboardForCompatibilityCheck } from './utils/communityDashboardHelpers';
jest.mock('./api/dashboardLibraryApi', () => ({
fetchCommunityDashboards: jest.fn(),
}));
jest.mock('./api/compatibilityApi', () => ({
checkDashboardCompatibility: jest.fn(),
}));
jest.mock('./utils/communityDashboardHelpers', () => ({
...jest.requireActual('./utils/communityDashboardHelpers'),
onUseCommunityDashboard: jest.fn(),
interpolateDashboardForCompatibilityCheck: jest.fn(),
}));
jest.mock('./interactions', () => ({
...jest.requireActual('./interactions'),
DashboardLibraryInteractions: {
loaded: jest.fn(),
searchPerformed: jest.fn(),
itemClicked: jest.fn(),
compatibilityCheckTriggered: jest.fn(),
compatibilityCheckCompleted: jest.fn(),
},
}));
// Track the datasource type for mocking
let mockDatasourceType = 'prometheus';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
getInstanceSettings: jest.fn((uid: string) => ({
uid,
name: `DataSource ${uid}`,
type: 'test',
type: mockDatasourceType,
})),
}),
}));
const mockFetchCommunityDashboards = fetchCommunityDashboards as jest.MockedFunction<typeof fetchCommunityDashboards>;
const mockOnUseCommunityDashboard = onUseCommunityDashboard as jest.MockedFunction<typeof onUseCommunityDashboard>;
const mockInterpolateDashboard = interpolateDashboardForCompatibilityCheck as jest.MockedFunction<
typeof interpolateDashboardForCompatibilityCheck
>;
const mockCheckCompatibility = checkDashboardCompatibility as jest.MockedFunction<typeof checkDashboardCompatibility>;
const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
id: 1,
@@ -40,15 +67,50 @@ const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDa
...overrides,
});
/**
* Creates a mock DashboardJson object for testing.
* Only includes the minimal required fields since `checkDashboardCompatibility` is mocked
* and doesn't actually process the dashboard structure.
*/
const createMockDashboardJson = (overrides: Partial<DashboardJson> = {}): DashboardJson =>
({
title: 'Test Dashboard',
schemaVersion: 38,
panels: [],
...overrides,
}) as DashboardJson;
const createMockCompatibilityResult = (
overrides: Partial<CompatibilityCheckResult> = {}
): CompatibilityCheckResult => ({
compatibilityScore: 0.85,
datasourceResults: [
{
uid: 'test-ds',
type: 'prometheus',
name: 'Test Prometheus',
totalQueries: 10,
checkedQueries: 10,
totalMetrics: 20,
foundMetrics: 17,
missingMetrics: ['missing_metric_1', 'missing_metric_2', 'missing_metric_3'],
compatibilityScore: 0.85,
queryBreakdown: [],
},
],
...overrides,
});
const setup = async (
props: Partial<React.ComponentProps<typeof CommunityDashboardSection>> = {},
successScenario = true
successScenario = true,
datasourceUid = 'test-datasource-uid'
) => {
const renderResult = render(
<CommunityDashboardSection onShowMapping={jest.fn()} datasourceType="test" {...props} />,
<CommunityDashboardSection onShowMapping={jest.fn()} datasourceType={mockDatasourceType} {...props} />,
{
historyOptions: {
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-datasource-uid'],
initialEntries: [`/test?dashboardLibraryDatasourceUid=${datasourceUid}`],
},
}
);
@@ -65,6 +127,7 @@ const setup = async (
describe('CommunityDashboardSection', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDatasourceType = 'prometheus';
});
it('should render', async () => {
@@ -78,6 +141,10 @@ describe('CommunityDashboardSection', () => {
],
});
// Mock compatibility check to prevent auto-check errors
mockInterpolateDashboard.mockResolvedValue(createMockDashboardJson());
mockCheckCompatibility.mockResolvedValue(createMockCompatibilityResult());
await setup();
await waitFor(() => {
@@ -94,6 +161,10 @@ describe('CommunityDashboardSection', () => {
items: [createMockGnetDashboard()],
});
// Mock compatibility check to prevent auto-check errors
mockInterpolateDashboard.mockResolvedValue(createMockDashboardJson());
mockCheckCompatibility.mockResolvedValue(createMockCompatibilityResult());
mockOnUseCommunityDashboard.mockRejectedValue(new Error('Failed to use community dashboard'));
const { user } = await setup();
@@ -122,4 +193,296 @@ describe('CommunityDashboardSection', () => {
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboards', expect.any(Error));
consoleErrorSpy.mockRestore();
});
describe('Compatibility Badge Feature', () => {
testWithFeatureToggles({ enable: ['dashboardValidatorApp'] });
it('should show "Check" button when datasource type is prometheus', async () => {
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
// Mock the auto-check to prevent it from running
mockInterpolateDashboard.mockResolvedValue(createMockDashboardJson());
mockCheckCompatibility.mockResolvedValue(createMockCompatibilityResult());
await setup();
// Wait for the loading state to show and then complete
await waitFor(() => {
// Either we see the Check button (if auto-check hasn't completed) or the success badge
const checkButtons = screen.queryAllByRole('button', { name: 'Check' });
const successBadges = screen.queryAllByTestId('compatibility-badge-success');
const loadingBadges = screen.queryAllByTestId('compatibility-badge-loading');
expect(checkButtons.length + successBadges.length + loadingBadges.length).toBeGreaterThan(0);
});
});
it('should hide compatibility badge when datasource type is not prometheus', async () => {
mockDatasourceType = 'influxdb';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
await setup();
await waitFor(() => {
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: 'Check' })).not.toBeInTheDocument();
expect(screen.queryByTestId('compatibility-badge-loading')).not.toBeInTheDocument();
});
it('should hide compatibility badge when no datasourceUid in URL', async () => {
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
// Render without datasourceUid in URL
render(<CommunityDashboardSection onShowMapping={jest.fn()} datasourceType="prometheus" />, {
historyOptions: {
initialEntries: ['/test'],
},
});
// Wait for component to finish initial rendering
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Check' })).not.toBeInTheDocument();
});
});
it('should auto-trigger compatibility check on initial load for prometheus', async () => {
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
mockInterpolateDashboard.mockResolvedValue(createMockDashboardJson({ title: 'Interpolated' }));
mockCheckCompatibility.mockResolvedValue(createMockCompatibilityResult());
await setup();
// Wait for auto-check to be triggered
await waitFor(() => {
expect(mockInterpolateDashboard).toHaveBeenCalledWith(1, 'test-datasource-uid');
});
await waitFor(() => {
expect(mockCheckCompatibility).toHaveBeenCalled();
});
});
it('should track analytics when compatibility check is triggered', async () => {
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
mockInterpolateDashboard.mockResolvedValue(createMockDashboardJson({ title: 'Interpolated' }));
mockCheckCompatibility.mockResolvedValue(createMockCompatibilityResult());
await setup();
await waitFor(() => {
expect(DashboardLibraryInteractions.compatibilityCheckTriggered).toHaveBeenCalledWith(
expect.objectContaining({
dashboardId: '1',
dashboardTitle: 'Test Dashboard',
datasourceType: 'prometheus',
triggerMethod: 'auto_initial_load',
})
);
});
});
it('should track analytics when compatibility check completes', async () => {
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
mockInterpolateDashboard.mockResolvedValue(createMockDashboardJson({ title: 'Interpolated' }));
mockCheckCompatibility.mockResolvedValue(createMockCompatibilityResult());
await setup();
await waitFor(() => {
expect(DashboardLibraryInteractions.compatibilityCheckCompleted).toHaveBeenCalledWith(
expect.objectContaining({
dashboardId: '1',
dashboardTitle: 'Test Dashboard',
datasourceType: 'prometheus',
score: 85,
metricsFound: 17,
metricsTotal: 20,
triggerMethod: 'auto_initial_load',
})
);
});
});
it('should show success badge after compatibility check completes', async () => {
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
mockInterpolateDashboard.mockResolvedValue(createMockDashboardJson({ title: 'Interpolated' }));
mockCheckCompatibility.mockResolvedValue(createMockCompatibilityResult());
await setup();
await waitFor(() => {
expect(screen.getByTestId('compatibility-badge-success')).toBeInTheDocument();
expect(screen.getByText('85% compatible')).toBeInTheDocument();
});
});
it('should show error badge when compatibility check fails', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
mockInterpolateDashboard.mockRejectedValue(new Error('Failed to interpolate dashboard'));
await setup();
await waitFor(() => {
expect(screen.getByTestId('compatibility-badge-error')).toBeInTheDocument();
});
consoleErrorSpy.mockRestore();
});
it('should allow manual check when clicking Check button', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
// First call fails (simulating search result state), second call succeeds
mockInterpolateDashboard
.mockRejectedValueOnce(new Error('First auto-check fails'))
.mockResolvedValueOnce(createMockDashboardJson({ title: 'Interpolated' }));
mockCheckCompatibility.mockResolvedValue(createMockCompatibilityResult());
const { user } = await setup();
// Wait for error badge from auto-check
await waitFor(() => {
expect(screen.getByTestId('compatibility-badge-error')).toBeInTheDocument();
});
// Click to retry
await user.click(screen.getByTestId('compatibility-badge-error'));
// Should show success after retry
await waitFor(() => {
expect(screen.getByTestId('compatibility-badge-success')).toBeInTheDocument();
});
consoleErrorSpy.mockRestore();
});
it('should show loading state during compatibility check', async () => {
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
// Create a promise that we control
let resolveCheck: (value: CompatibilityCheckResult) => void;
const checkPromise = new Promise<CompatibilityCheckResult>((resolve) => {
resolveCheck = resolve;
});
mockInterpolateDashboard.mockResolvedValue(createMockDashboardJson({ title: 'Interpolated' }));
mockCheckCompatibility.mockReturnValue(checkPromise);
await setup();
// Should show loading badge while check is in progress
await waitFor(() => {
expect(screen.getByTestId('compatibility-badge-loading')).toBeInTheDocument();
});
// Resolve the check
resolveCheck!(createMockCompatibilityResult());
// Should show success badge after check completes
await waitFor(() => {
expect(screen.getByTestId('compatibility-badge-success')).toBeInTheDocument();
});
});
});
describe('When dashboardValidatorApp is disabled', () => {
testWithFeatureToggles({ disable: ['dashboardValidatorApp'] });
it('should not show compatibility badge for prometheus datasources', async () => {
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
await setup();
await waitFor(() => {
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
});
// Verify badge is not rendered
expect(screen.queryByRole('button', { name: /Check/i })).not.toBeInTheDocument();
expect(screen.queryByTestId('compatibility-badge-loading')).not.toBeInTheDocument();
expect(screen.queryByTestId('compatibility-badge-success')).not.toBeInTheDocument();
});
it('should not trigger auto-checks on initial load', async () => {
mockDatasourceType = 'prometheus';
mockFetchCommunityDashboards.mockResolvedValue({
page: 1,
pages: 5,
items: [createMockGnetDashboard()],
});
await setup();
await waitFor(() => {
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
});
// Verify no API calls were made
expect(mockInterpolateDashboard).not.toHaveBeenCalled();
expect(mockCheckCompatibility).not.toHaveBeenCalled();
expect(DashboardLibraryInteractions.compatibilityCheckTriggered).not.toHaveBeenCalled();
});
});
});
@@ -1,15 +1,17 @@
import { css } from '@emotion/css';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { useAsyncFn, useAsyncRetry, useDebounce } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { config, getDataSourceSrv, isFetchError } from '@grafana/runtime';
import { Button, useStyles2, Stack, Grid, EmptyState, Alert, FilterInput, Box } from '@grafana/ui';
import { CompatibilityState } from './CompatibilityBadge';
import { DashboardCard } from './DashboardCard';
import { MappingContext } from './SuggestedDashboardsModal';
import { checkDashboardCompatibility } from './api/compatibilityApi';
import { fetchCommunityDashboards } from './api/dashboardLibraryApi';
import {
CONTENT_KINDS,
@@ -18,12 +20,13 @@ import {
EVENT_LOCATIONS,
SOURCE_ENTRY_POINTS,
} from './interactions';
import { GnetDashboard } from './types';
import { GnetDashboard, isGnetDashboard } from './types';
import {
getThumbnailUrl,
getLogoUrl,
buildDashboardDetails,
onUseCommunityDashboard,
interpolateDashboardForCompatibilityCheck,
COMMUNITY_PAGE_SIZE_QUERY,
COMMUNITY_RESULT_SIZE,
} from './utils/communityDashboardHelpers';
@@ -44,6 +47,12 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid');
const [searchQuery, setSearchQuery] = useState('');
const hasTrackedLoaded = useRef(false);
const isCompatibilityAppEnabled = config.featureToggles.dashboardValidatorApp;
// New state for compatibility badge feature
const [compatibilityMap, setCompatibilityMap] = useState<Map<number, CompatibilityState>>(new Map());
const [isInitialLoad, setIsInitialLoad] = useState(true);
const hasAutoCheckedRef = useRef(false);
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
useDebounce(
@@ -54,6 +63,13 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
[searchQuery]
);
// Reset initial load flag when search query changes
useEffect(() => {
if (debouncedSearchQuery.trim()) {
setIsInitialLoad(false);
}
}, [debouncedSearchQuery]);
const {
value: response,
loading,
@@ -151,6 +167,104 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
[response, datasourceUid, debouncedSearchQuery, onShowMapping]
);
// Handler for checking compatibility of a single dashboard
const handleCheckCompatibility = useCallback(
async (dashboard: GnetDashboard, triggerMethod: 'manual' | 'auto_initial_load') => {
if (!datasourceUid || !response?.datasourceType) {
return;
}
// Set loading state
setCompatibilityMap((prev) => new Map(prev).set(dashboard.id, { status: 'loading' }));
// Track analytics: check triggered
DashboardLibraryInteractions.compatibilityCheckTriggered({
dashboardId: String(dashboard.id),
dashboardTitle: dashboard.name,
datasourceType: response.datasourceType,
triggerMethod,
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
});
try {
const interpolatedDashboard = await interpolateDashboardForCompatibilityCheck(dashboard.id, datasourceUid);
// Call compatibility API directly
const result = await checkDashboardCompatibility(interpolatedDashboard, [
{
uid: datasourceUid,
type: response.datasourceType,
name: getDataSourceSrv().getInstanceSettings(datasourceUid)?.name ?? '',
},
]);
// Calculate metrics from first datasource result
const dsResult = result.datasourceResults[0];
const score = Math.round(dsResult.compatibilityScore * 100);
const metricsFound = dsResult.foundMetrics;
const metricsTotal = dsResult.totalMetrics;
// Update state with success
setCompatibilityMap((prev) =>
new Map(prev).set(dashboard.id, {
status: 'success',
score,
metricsFound,
metricsTotal,
})
);
// Track analytics: check completed
DashboardLibraryInteractions.compatibilityCheckCompleted({
dashboardId: String(dashboard.id),
dashboardTitle: dashboard.name,
datasourceType: response.datasourceType,
score,
metricsFound,
metricsTotal,
triggerMethod,
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
});
} catch (err) {
console.error('Error checking dashboard compatibility:', err);
const errorMessage = isFetchError(err) ? err.data?.message : 'Failed to check compatibility';
const errorCode = isFetchError(err) ? err.data?.code : undefined;
setCompatibilityMap((prev) =>
new Map(prev).set(dashboard.id, {
status: 'error',
errorMessage,
errorCode,
})
);
}
},
[datasourceUid, response]
);
// Auto-trigger compatibility checks on initial load for Prometheus datasources
useEffect(() => {
if (
!loading &&
isInitialLoad &&
!hasAutoCheckedRef.current &&
response?.dashboards &&
response.dashboards.length > 0 &&
datasourceUid &&
response.datasourceType === 'prometheus' &&
isCompatibilityAppEnabled
) {
hasAutoCheckedRef.current = true;
// Trigger checks for all dashboards on initial load
// currently 6 dashboards in total
response.dashboards.forEach((dashboard) => {
handleCheckCompatibility(dashboard, 'auto_initial_load');
});
}
}, [loading, isInitialLoad, response, datasourceUid, handleCheckCompatibility, isCompatibilityAppEnabled]);
return (
<Stack direction="column" gap={2} height="100%">
{isPreviewDashboardError && (
@@ -253,6 +367,10 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
const isLogo = !thumbnailUrl;
const details = buildDashboardDetails(dashboard);
// Only show badge for Prometheus datasources
const showBadge =
isCompatibilityAppEnabled && !!datasourceUid && response?.datasourceType === 'prometheus';
return (
<DashboardCard
key={dashboard.id}
@@ -263,6 +381,13 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
isLogo={isLogo}
details={details}
kind="suggested_dashboard"
showCompatibilityBadge={showBadge}
compatibilityState={compatibilityMap.get(dashboard.id)}
onCompatibilityCheck={
showBadge && isGnetDashboard(dashboard)
? () => handleCheckCompatibility(dashboard, 'manual')
: undefined
}
/>
);
})}
@@ -0,0 +1,293 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from 'test/test-utils';
import { CompatibilityBadge, CompatibilityState } from './CompatibilityBadge';
describe('CompatibilityBadge', () => {
const mockOnCheck = jest.fn();
const mockOnRetry = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
describe('idle state', () => {
it('should render "Check" button when status is idle', () => {
const state: CompatibilityState = { status: 'idle' };
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} />);
expect(screen.getByRole('button', { name: 'Check compatibility' })).toBeInTheDocument();
});
it('should call onCheck when "Check" button is clicked', async () => {
const state: CompatibilityState = { status: 'idle' };
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} />);
await userEvent.click(screen.getByRole('button', { name: 'Check compatibility' }));
expect(mockOnCheck).toHaveBeenCalledTimes(1);
});
it('should stop event propagation when "Check" button is clicked', async () => {
const state: CompatibilityState = { status: 'idle' };
const mockParentClick = jest.fn();
render(
<div onClick={mockParentClick}>
<CompatibilityBadge state={state} onCheck={mockOnCheck} />
</div>
);
await userEvent.click(screen.getByRole('button', { name: 'Check compatibility' }));
expect(mockOnCheck).toHaveBeenCalledTimes(1);
expect(mockParentClick).not.toHaveBeenCalled();
});
});
describe('loading state', () => {
it('should render disabled button with "Checking" text and spinner when status is loading', () => {
const state: CompatibilityState = { status: 'loading' };
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} />);
const button = screen.getByRole('button', { name: 'Checking' });
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Checking');
expect(screen.getByTestId('compatibility-badge-loading')).toBeInTheDocument();
});
it('should not call onCheck when disabled button is clicked', async () => {
const state: CompatibilityState = { status: 'loading' };
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} />);
const button = screen.getByRole('button', { name: 'Checking' });
await userEvent.click(button);
expect(mockOnCheck).not.toHaveBeenCalled();
});
});
describe('success state', () => {
it('should render badge with score when score >= 80%', () => {
const state: CompatibilityState = {
status: 'success',
score: 85,
metricsFound: 17,
metricsTotal: 20,
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} />);
const badge = screen.getByTestId('compatibility-badge-success');
expect(badge).toBeInTheDocument();
expect(badge).toHaveTextContent('85%');
});
it('should render badge with score when score is 50-79%', () => {
const state: CompatibilityState = {
status: 'success',
score: 65,
metricsFound: 13,
metricsTotal: 20,
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} />);
const badge = screen.getByTestId('compatibility-badge-success');
expect(badge).toBeInTheDocument();
expect(badge).toHaveTextContent('65%');
});
it('should render badge with score when score < 50%', () => {
const state: CompatibilityState = {
status: 'success',
score: 30,
metricsFound: 6,
metricsTotal: 20,
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} />);
const badge = screen.getByTestId('compatibility-badge-success');
expect(badge).toBeInTheDocument();
expect(badge).toHaveTextContent('30%');
});
it('should handle edge case score of exactly 80%', () => {
const state: CompatibilityState = {
status: 'success',
score: 80,
metricsFound: 16,
metricsTotal: 20,
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} />);
// Score of 80 should be green (>= 80)
const badge = screen.getByTestId('compatibility-badge-success');
expect(badge).toHaveTextContent('80%');
});
it('should handle edge case score of exactly 50%', () => {
const state: CompatibilityState = {
status: 'success',
score: 50,
metricsFound: 10,
metricsTotal: 20,
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} />);
// Score of 50 should be orange (>= 50)
const badge = screen.getByTestId('compatibility-badge-success');
expect(badge).toHaveTextContent('50%');
});
it('should handle 0% score', () => {
const state: CompatibilityState = {
status: 'success',
score: 0,
metricsFound: 0,
metricsTotal: 20,
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} />);
const badge = screen.getByTestId('compatibility-badge-success');
expect(badge).toHaveTextContent('0%');
});
it('should handle 100% score', () => {
const state: CompatibilityState = {
status: 'success',
score: 100,
metricsFound: 20,
metricsTotal: 20,
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} />);
const badge = screen.getByTestId('compatibility-badge-success');
expect(badge).toHaveTextContent('100%');
});
});
describe('error state', () => {
it('should render error badge when status is error', () => {
const state: CompatibilityState = {
status: 'error',
errorMessage: 'API request failed',
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} onRetry={mockOnRetry} />);
expect(screen.getByTestId('compatibility-badge-error')).toBeInTheDocument();
});
it('should call onRetry when error badge is clicked', async () => {
const state: CompatibilityState = {
status: 'error',
errorMessage: 'API request failed',
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} onRetry={mockOnRetry} />);
await userEvent.click(screen.getByTestId('compatibility-badge-error'));
expect(mockOnRetry).toHaveBeenCalledTimes(1);
});
it('should render error badge when errorMessage is not provided', () => {
const state: CompatibilityState = {
status: 'error',
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} onRetry={mockOnRetry} />);
expect(screen.getByTestId('compatibility-badge-error')).toBeInTheDocument();
});
it('should stop event propagation when error badge is clicked', async () => {
const state: CompatibilityState = {
status: 'error',
errorMessage: 'Error',
};
const mockParentClick = jest.fn();
render(
<div onClick={mockParentClick}>
<CompatibilityBadge state={state} onCheck={mockOnCheck} onRetry={mockOnRetry} />
</div>
);
await userEvent.click(screen.getByTestId('compatibility-badge-error'));
expect(mockOnRetry).toHaveBeenCalledTimes(1);
expect(mockParentClick).not.toHaveBeenCalled();
});
it('should show not-supported tooltip for datasource_wrong_type', async () => {
const state: CompatibilityState = {
status: 'error',
errorCode: 'datasource_wrong_type',
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} onRetry={mockOnRetry} />);
await userEvent.hover(screen.getByTestId('compatibility-badge-error'));
expect(await screen.findByText(/not yet supported/i)).toBeInTheDocument();
});
it('should show not-supported tooltip for unsupported_dashboard_version', async () => {
const state: CompatibilityState = {
status: 'error',
errorCode: 'unsupported_dashboard_version',
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} onRetry={mockOnRetry} />);
await userEvent.hover(screen.getByTestId('compatibility-badge-error'));
expect(await screen.findByText(/not yet supported/i)).toBeInTheDocument();
});
it('should show not-supported tooltip for invalid_dashboard', async () => {
const state: CompatibilityState = {
status: 'error',
errorCode: 'invalid_dashboard',
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} onRetry={mockOnRetry} />);
await userEvent.hover(screen.getByTestId('compatibility-badge-error'));
expect(await screen.findByText(/not yet supported/i)).toBeInTheDocument();
});
it('should show troubleshooting tooltip for unexpected errors', async () => {
const state: CompatibilityState = {
status: 'error',
errorCode: 'datasource_auth_failed',
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} onRetry={mockOnRetry} />);
await userEvent.hover(screen.getByTestId('compatibility-badge-error'));
expect(await screen.findByText(/Compatibility check failed/i)).toBeInTheDocument();
});
it('should show troubleshooting tooltip when errorCode is not provided', async () => {
const state: CompatibilityState = {
status: 'error',
errorMessage: 'Unknown error',
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} onRetry={mockOnRetry} />);
await userEvent.hover(screen.getByTestId('compatibility-badge-error'));
expect(await screen.findByText(/Compatibility check failed/i)).toBeInTheDocument();
});
it('should allow retry on not_supported errors', async () => {
const state: CompatibilityState = {
status: 'error',
errorCode: 'datasource_wrong_type',
};
render(<CompatibilityBadge state={state} onCheck={mockOnCheck} onRetry={mockOnRetry} />);
await userEvent.click(screen.getByTestId('compatibility-badge-error'));
expect(mockOnRetry).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,253 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { Badge, Button, Spinner, Tooltip, useStyles2 } from '@grafana/ui';
/**
* Discriminated union for compatibility check states.
* Each state variant only includes fields relevant to that state,
* making invalid states unrepresentable at the type level.
*/
export type CompatibilityState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; score: number; metricsFound: number; metricsTotal: number }
| { status: 'error'; errorMessage?: string; errorCode?: string };
export type ErrorCategory = 'not_supported' | 'unexpected';
interface ScoreIndicator {
color: 'green' | 'orange' | 'red';
icon: 'check-circle' | 'exclamation-triangle' | 'times-circle';
tooltip: string;
}
interface CompatibilityBadgeProps {
state: CompatibilityState;
onCheck: () => void;
onRetry?: () => void;
}
const NOT_SUPPORTED_CODES = ['datasource_wrong_type', 'unsupported_dashboard_version', 'invalid_dashboard'];
/**
* Compact inline badge that displays dashboard compatibility status.
*
* States:
* - idle: Shows "Check" button to trigger compatibility check
* - loading: Shows "Check" button disabled with spinner (Grafana pattern)
* - success: Shows score with color coding (green ≥80%, orange 50-79%, red <50%)
* - error: Shows error badge with retry option
*/
export const CompatibilityBadge = ({ state, onCheck, onRetry }: CompatibilityBadgeProps) => {
const styles = useStyles2(getStyles);
const isLoading = state.status === 'loading';
if (state.status === 'idle' || state.status === 'loading') {
const buttonText = isLoading
? t('dashboard-library.compatibility-badge.checking', 'Checking')
: t('dashboard-library.compatibility-badge.check', 'Check compatibility');
const tooltipContent = t(
'dashboard-library.compatibility-badge.check-tooltip',
'Checks how many dashboard metrics match your data source.'
);
return (
<Tooltip interactive={true} content={tooltipContent}>
<Button
variant="secondary"
fill="outline"
disabled={isLoading}
onClick={(e) => {
e.stopPropagation();
onCheck();
}}
aria-label={buttonText}
data-testid={isLoading ? 'compatibility-badge-loading' : undefined}
className={styles.button}
icon="info-circle"
>
{buttonText}
{isLoading && <Spinner size="xs" inline className={styles.spinner} />}
</Button>
</Tooltip>
);
}
if (state.status === 'error') {
const tooltipContent = getErrorTooltip(state.errorCode, styles.tooltipLink);
return (
<Tooltip interactive={true} content={tooltipContent}>
<span
className={styles.badgeWrapper}
onClick={(e) => {
e.stopPropagation();
onRetry?.();
}}
data-testid="compatibility-badge-error"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
onRetry?.();
}
}}
>
<Badge
text={t('dashboard-library.compatibility-badge.error-text', 'Error')}
icon="exclamation-circle"
color="red"
className={styles.clickableBadge}
/>
</span>
</Tooltip>
);
}
if (state.status === 'success') {
const {
color,
icon,
tooltip: tooltipContent,
} = getScoreIndicator(state.score, state.metricsFound, state.metricsTotal);
return (
<Tooltip interactive={true} content={tooltipContent}>
<span className={styles.badgeWrapper} data-testid="compatibility-badge-success">
<Badge
text={t('dashboard-library.compatibility-badge.score-text', '{{score}}% compatible', {
score: state.score,
})}
icon={icon}
color={color}
className={styles.badge}
/>
</span>
</Tooltip>
);
}
return null;
};
function categorizeError(errorCode?: string): ErrorCategory {
if (errorCode && NOT_SUPPORTED_CODES.includes(errorCode)) {
return 'not_supported';
}
return 'unexpected';
}
/**
* Returns color, icon, and tooltip for a compatibility score.
* Thresholds: green ≥80%, orange 50-79%, red <50%.
*/
function getScoreIndicator(score: number, metricsFound: number, metricsTotal: number): ScoreIndicator {
if (score >= 80) {
return {
color: 'green',
icon: 'check-circle',
tooltip: t(
'dashboard-library.compatibility-badge.tooltip-green',
'{{score}}% ({{found}}/{{total}}) of metrics match.',
{ score, found: metricsFound, total: metricsTotal }
),
};
}
if (score >= 50) {
return {
color: 'orange',
icon: 'exclamation-triangle',
tooltip: t(
'dashboard-library.compatibility-badge.tooltip-orange',
'{{score}}% ({{found}}/{{total}}) of metrics match. This dashboard may contain panels that require heavy customization.',
{ score, found: metricsFound, total: metricsTotal }
),
};
}
return {
color: 'red',
icon: 'times-circle',
tooltip: t(
'dashboard-library.compatibility-badge.tooltip-red',
'{{score}}% ({{found}}/{{total}}) of metrics match. This dashboard will require heavy query customization.',
{ score, found: metricsFound, total: metricsTotal }
),
};
}
function getErrorTooltip(errorCode: string | undefined, linkClassName: string) {
const category = categorizeError(errorCode);
if (category === 'not_supported') {
return (
<Trans i18nKey="dashboard-library.compatibility-badge.not-supported-tooltip">
This dashboard or datasource type is not yet supported. Only Prometheus datasources and non-dynamic dashboards
(v1) are currently supported.
</Trans>
);
}
return (
<Trans i18nKey="dashboard-library.compatibility-badge.error-tooltip">
Compatibility check failed. First, verify the{' '}
<a
href="https://grafana.com/docs/grafana/latest/datasources/"
target="_blank"
rel="noopener noreferrer"
className={linkClassName}
>
data source
</a>{' '}
is working. Then open the dashboard and review the{' '}
<a
href="https://grafana.com/docs/grafana/latest/visualizations/dashboards/build-dashboards/modify-dashboard-settings/"
target="_blank"
rel="noopener noreferrer"
className={linkClassName}
>
variables
</a>
.
</Trans>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
button: css({
minWidth: theme.spacing(12),
justifyContent: 'center',
}),
spinner: css({
marginLeft: theme.spacing(1),
}),
badgeWrapper: css({
display: 'inline-flex',
alignItems: 'center',
}),
badge: css({
paddingTop: theme.spacing(0.8),
paddingBottom: theme.spacing(0.8),
}),
clickableBadge: css({
paddingTop: theme.spacing(0.8),
paddingBottom: theme.spacing(0.8),
cursor: 'pointer',
'&:hover': {
opacity: 0.8,
},
}),
tooltipLink: css({
color: theme.colors.text.link,
textDecoration: 'underline',
'&:hover': {
textDecoration: 'none',
},
}),
};
}
@@ -1,5 +1,5 @@
import { screen } from '@testing-library/react';
import { render } from 'test/test-utils';
import { render, testWithFeatureToggles } from 'test/test-utils';
import { DashboardCard } from './DashboardCard';
import { createMockGnetDashboard, createMockPluginDashboard } from './utils/test-utils';
@@ -313,4 +313,192 @@ describe('DashboardCard', () => {
expect(screen.getByRole('heading', { name: 'Community Dashboard' })).toBeInTheDocument();
});
});
describe('Compatibility badge', () => {
testWithFeatureToggles({ enable: ['dashboardValidatorApp'] });
it('should show Check button when showCompatibilityBadge={true} and onCompatibilityCheck is provided', () => {
const mockOnCompatibilityCheck = jest.fn();
render(
<DashboardCard
title="Test Dashboard"
dashboard={createMockPluginDashboard()}
onClick={mockOnClick}
showCompatibilityBadge={true}
onCompatibilityCheck={mockOnCompatibilityCheck}
kind="suggested_dashboard"
/>
);
expect(screen.getByRole('button', { name: 'Check compatibility' })).toBeInTheDocument();
});
it('should not show compatibility badge when showCompatibilityBadge={false}', () => {
const mockOnCompatibilityCheck = jest.fn();
render(
<DashboardCard
title="Test Dashboard"
dashboard={createMockPluginDashboard()}
onClick={mockOnClick}
showCompatibilityBadge={false}
onCompatibilityCheck={mockOnCompatibilityCheck}
kind="suggested_dashboard"
/>
);
expect(screen.queryByRole('button', { name: 'Check compatibility' })).not.toBeInTheDocument();
});
it('should not show compatibility badge when onCompatibilityCheck is not provided', () => {
render(
<DashboardCard
title="Test Dashboard"
dashboard={createMockPluginDashboard()}
onClick={mockOnClick}
showCompatibilityBadge={true}
kind="suggested_dashboard"
/>
);
expect(screen.queryByRole('button', { name: 'Check compatibility' })).not.toBeInTheDocument();
});
it('should call onCompatibilityCheck when Check button is clicked', async () => {
const mockOnCompatibilityCheck = jest.fn();
const { user } = render(
<DashboardCard
title="Test Dashboard"
dashboard={createMockPluginDashboard()}
onClick={mockOnClick}
showCompatibilityBadge={true}
onCompatibilityCheck={mockOnCompatibilityCheck}
kind="suggested_dashboard"
/>
);
await user.click(screen.getByRole('button', { name: 'Check compatibility' }));
expect(mockOnCompatibilityCheck).toHaveBeenCalledTimes(1);
});
it('should prevent event propagation when Check button is clicked', async () => {
const mockOnCompatibilityCheck = jest.fn();
const mockParentClick = jest.fn();
const { user } = render(
<div onClick={mockParentClick}>
<DashboardCard
title="Test Dashboard"
dashboard={createMockPluginDashboard()}
onClick={mockOnClick}
showCompatibilityBadge={true}
onCompatibilityCheck={mockOnCompatibilityCheck}
kind="suggested_dashboard"
/>
</div>
);
await user.click(screen.getByRole('button', { name: 'Check compatibility' }));
expect(mockParentClick).not.toHaveBeenCalled();
expect(mockOnCompatibilityCheck).toHaveBeenCalledTimes(1);
});
it('should show success badge with score when compatibilityState has success status', () => {
render(
<DashboardCard
title="Test Dashboard"
dashboard={createMockPluginDashboard()}
onClick={mockOnClick}
showCompatibilityBadge={true}
onCompatibilityCheck={jest.fn()}
compatibilityState={{ status: 'success', score: 85, metricsFound: 17, metricsTotal: 20 }}
kind="suggested_dashboard"
/>
);
expect(screen.getByTestId('compatibility-badge-success')).toBeInTheDocument();
expect(screen.getByText('85% compatible')).toBeInTheDocument();
});
it('should show loading state when compatibilityState has loading status', () => {
render(
<DashboardCard
title="Test Dashboard"
dashboard={createMockPluginDashboard()}
onClick={mockOnClick}
showCompatibilityBadge={true}
onCompatibilityCheck={jest.fn()}
compatibilityState={{ status: 'loading' }}
kind="suggested_dashboard"
/>
);
expect(screen.getByTestId('compatibility-badge-loading')).toBeInTheDocument();
});
it('should render buttons in correct order: details (in title), primary, compatibility badge', () => {
const details = createMockDetails();
render(
<DashboardCard
title="Test Dashboard"
dashboard={createMockPluginDashboard()}
onClick={mockOnClick}
details={details}
showCompatibilityBadge={true}
onCompatibilityCheck={jest.fn()}
kind="suggested_dashboard"
/>
);
// With dashboardValidatorApp enabled, details button moves into the title row
const buttons = screen.getAllByRole('button');
expect(buttons[0]).toHaveAttribute('aria-label', 'Details');
expect(buttons[1]).toHaveTextContent('Use dashboard');
expect(buttons[2]).toHaveTextContent('Check compatibility');
});
});
describe('dashboardValidatorApp Feature Flag Gating', () => {
describe('when dashboardValidatorApp is disabled', () => {
testWithFeatureToggles({ disable: ['dashboardValidatorApp'] });
it('should hide compatibility badge even when showCompatibilityBadge is true', () => {
const mockOnCompatibilityCheck = jest.fn();
render(
<DashboardCard
title="Test Dashboard"
dashboard={createMockPluginDashboard()}
onClick={jest.fn()}
showCompatibilityBadge={true}
onCompatibilityCheck={mockOnCompatibilityCheck}
kind="suggested_dashboard"
/>
);
expect(screen.queryByRole('button', { name: /Check/i })).not.toBeInTheDocument();
});
});
describe('when dashboardValidatorApp is enabled', () => {
testWithFeatureToggles({ enable: ['dashboardValidatorApp'] });
it('should show compatibility badge when showCompatibilityBadge is true', () => {
const mockOnCompatibilityCheck = jest.fn();
render(
<DashboardCard
title="Test Dashboard"
dashboard={createMockPluginDashboard()}
onClick={jest.fn()}
showCompatibilityBadge={true}
onCompatibilityCheck={mockOnCompatibilityCheck}
kind="suggested_dashboard"
/>
);
expect(screen.getByRole('button', { name: /Check/i })).toBeInTheDocument();
});
});
});
});
@@ -3,10 +3,12 @@ import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Badge, Box, Button, Card, IconButton, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui';
import { attachSkeleton, SkeletonComponent } from '@grafana/ui/unstable';
import { PluginDashboard } from 'app/types/plugins';
import { CompatibilityBadge, CompatibilityState } from './CompatibilityBadge';
import { GnetDashboard } from './types';
interface Details {
@@ -28,6 +30,12 @@ interface Props {
showDatasourceProvidedBadge?: boolean;
dimThumbnail?: boolean; // Apply 50% opacity to thumbnail when badge is shown
kind: 'template_dashboard' | 'suggested_dashboard';
/** Show the compact compatibility badge (replaces showCompatibilityButton) */
showCompatibilityBadge?: boolean;
/** State for the compatibility badge (idle, loading, success, error) */
compatibilityState?: CompatibilityState;
/** Handler called when Check button is clicked in the badge */
onCompatibilityCheck?: () => void;
}
function DashboardCardComponent({
@@ -40,12 +48,35 @@ function DashboardCardComponent({
showDatasourceProvidedBadge,
dimThumbnail,
kind,
showCompatibilityBadge,
compatibilityState,
onCompatibilityCheck,
}: Props) {
const styles = useStyles2(getStyles);
const isCompatibilityAppEnabled = config.featureToggles.dashboardValidatorApp;
const detailsButton = details && (
<Tooltip interactive={true} content={<DetailsTooltipContent details={details} />} placement="right">
<IconButton
name="info-circle"
size={isCompatibilityAppEnabled ? 'sm' : 'xl'}
aria-label={t('dashboard-library.card.details-tooltip', 'Details')}
/>
</Tooltip>
);
return (
<Card className={styles.card} noMargin>
<Card.Heading className={styles.title}>{title}</Card.Heading>
<Card.Heading className={styles.title}>
{isCompatibilityAppEnabled ? (
<span className={styles.titleWithInfo}>
<span className={styles.titleText}>{title}</span>
{detailsButton}
</span>
) : (
title
)}
</Card.Heading>
<div className={isLogo ? styles.logoContainer : styles.thumbnailContainer}>
{imageUrl ? (
<img
@@ -90,14 +121,13 @@ function DashboardCardComponent({
<Trans i18nKey="dashboard-library.card.use-dashboard-button">Use dashboard</Trans>
)}
</Button>
{details && (
<Tooltip interactive={true} content={<DetailsTooltipContent details={details} />} placement="right">
<IconButton
name="info-circle"
size="xl"
aria-label={t('dashboard-library.card.details-tooltip', 'Details')}
/>
</Tooltip>
{!isCompatibilityAppEnabled && detailsButton}
{isCompatibilityAppEnabled && showCompatibilityBadge && onCompatibilityCheck && (
<CompatibilityBadge
state={compatibilityState ?? { status: 'idle' }}
onCheck={onCompatibilityCheck}
onRetry={onCompatibilityCheck}
/>
)}
</Card.Actions>
</Card>
@@ -227,6 +257,19 @@ function getStyles(theme: GrafanaTheme2) {
overflow: 'hidden',
textOverflow: 'ellipsis',
}),
titleWithInfo: css({
display: 'inline-flex',
alignItems: 'center',
gap: theme.spacing(0.5),
maxWidth: '100%',
}),
titleText: css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flexShrink: 1,
minWidth: 0,
}),
description: css({
display: '-webkit-box',
WebkitLineClamp: 2,
@@ -237,7 +280,7 @@ function getStyles(theme: GrafanaTheme2) {
}),
actionsContainer: css({
marginTop: 0,
alignItems: 'stretch',
alignItems: 'center',
}),
detailsContainer: css({
width: '340px',
@@ -0,0 +1,362 @@
import { getAPINamespace } from '@grafana/api-clients';
import { BackendSrv, getBackendSrv } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { checkDashboardCompatibility, CompatibilityCheckResult, DatasourceMapping } from './compatibilityApi';
// Mock dependencies
jest.mock('@grafana/runtime', () => ({
getBackendSrv: jest.fn(),
}));
jest.mock('@grafana/api-clients', () => ({
getAPINamespace: jest.fn(),
}));
const mockGetBackendSrv = getBackendSrv as jest.MockedFunction<typeof getBackendSrv>;
const mockGetAPINamespace = getAPINamespace as jest.MockedFunction<typeof getAPINamespace>;
// Helper to create mock BackendSrv
const createMockBackendSrv = (overrides: Partial<BackendSrv> = {}): BackendSrv =>
({
post: jest.fn(),
...overrides,
}) as unknown as BackendSrv;
// Prometheus-specific query type (extends DataQuery)
interface PrometheusQuery extends DataQuery {
expr: string;
}
// Test fixtures
const createMockDashboard = (overrides: Partial<DashboardJson> = {}): DashboardJson => {
// Create a minimal dashboard for testing purposes
// Panels array is intentionally minimal - only includes fields needed for compatibility check
const dashboard: DashboardJson = {
title: 'Test Dashboard',
uid: 'test-uid',
schemaVersion: 39,
version: 1,
panels: [
{
id: 1,
type: 'graph',
title: 'CPU Usage',
datasource: {
type: 'prometheus',
uid: 'prometheus-uid-123',
},
targets: [
{
refId: 'A',
expr: 'rate(cpu_usage_total[5m])',
} as PrometheusQuery,
],
},
{
id: 2,
type: 'graph',
title: 'Memory Usage',
datasource: {
type: 'prometheus',
uid: 'prometheus-uid-123',
},
targets: [
{
refId: 'A',
expr: 'memory_usage_bytes',
} as PrometheusQuery,
],
},
] as unknown as DashboardJson['panels'],
...overrides,
};
return dashboard;
};
const createMockDatasourceMappings = (): DatasourceMapping[] => [
{
uid: 'prometheus-uid-123',
type: 'prometheus',
name: 'Production Prometheus',
},
];
describe('compatibilityApi', () => {
let mockPost: jest.MockedFunction<BackendSrv['post']>;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
mockPost = jest.fn();
mockGetBackendSrv.mockReturnValue(
createMockBackendSrv({
post: mockPost,
})
);
// Mock getAPINamespace to return 'default' (typical dev environment)
mockGetAPINamespace.mockReturnValue('default');
// Mock console.error to prevent test failures
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
});
afterEach(() => {
jest.clearAllMocks();
consoleErrorSpy.mockRestore();
});
describe('checkDashboardCompatibility', () => {
it('should successfully check compatibility with high score (100%)', async () => {
const mockResponse: CompatibilityCheckResult = {
compatibilityScore: 100,
datasourceResults: [
{
uid: 'prometheus-uid-123',
type: 'prometheus',
name: 'Production Prometheus',
totalQueries: 2,
checkedQueries: 2,
totalMetrics: 2,
foundMetrics: 2,
missingMetrics: [],
compatibilityScore: 100,
queryBreakdown: [
{
panelTitle: 'CPU Usage',
panelID: 1,
queryRefId: 'A',
totalMetrics: 1,
foundMetrics: 1,
missingMetrics: [],
compatibilityScore: 100,
},
{
panelTitle: 'Memory Usage',
panelID: 2,
queryRefId: 'A',
totalMetrics: 1,
foundMetrics: 1,
missingMetrics: [],
compatibilityScore: 100,
},
],
},
],
};
mockPost.mockResolvedValue(mockResponse);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
const result = await checkDashboardCompatibility(dashboard, mappings);
expect(result).toEqual(mockResponse);
expect(mockPost).toHaveBeenCalledWith(
'/apis/dashvalidator.grafana.app/v1alpha1/namespaces/default/check',
{
dashboardJson: dashboard,
datasourceMappings: mappings,
},
{
showErrorAlert: false,
}
);
});
it('should successfully check compatibility with partial score (50%)', async () => {
const mockResponse: CompatibilityCheckResult = {
compatibilityScore: 50,
datasourceResults: [
{
uid: 'prometheus-uid-123',
type: 'prometheus',
name: 'Production Prometheus',
totalQueries: 2,
checkedQueries: 2,
totalMetrics: 2,
foundMetrics: 1,
missingMetrics: ['http_request_duration_seconds'],
compatibilityScore: 50,
queryBreakdown: [
{
panelTitle: 'CPU Usage',
panelID: 1,
queryRefId: 'A',
totalMetrics: 1,
foundMetrics: 1,
missingMetrics: [],
compatibilityScore: 100,
},
{
panelTitle: 'Memory Usage',
panelID: 2,
queryRefId: 'A',
totalMetrics: 1,
foundMetrics: 0,
missingMetrics: ['http_request_duration_seconds'],
compatibilityScore: 0,
},
],
},
],
};
mockPost.mockResolvedValue(mockResponse);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
const result = await checkDashboardCompatibility(dashboard, mappings);
expect(result).toEqual(mockResponse);
expect(result.compatibilityScore).toBe(50);
expect(result.datasourceResults[0].missingMetrics).toContain('http_request_duration_seconds');
});
it('should handle HTTP 404 error (datasource not found)', async () => {
const error404 = {
status: 404,
data: {
message: 'Datasource not found',
code: 'datasource_not_found',
},
};
mockPost.mockRejectedValue(error404);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
// Should re-throw original error from getBackendSrv
await expect(checkDashboardCompatibility(dashboard, mappings)).rejects.toEqual(error404);
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith('Dashboard compatibility check failed:', error404);
});
it('should handle HTTP 401 error (authentication failure)', async () => {
const error401 = {
status: 401,
data: {
message: 'Authentication failed for datasource',
code: 'datasource_auth_failed',
},
};
mockPost.mockRejectedValue(error401);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await expect(checkDashboardCompatibility(dashboard, mappings)).rejects.toEqual(error401);
});
it('should handle HTTP 503 error (datasource unreachable)', async () => {
const error503 = {
status: 503,
data: {
message: 'Datasource is unreachable',
code: 'datasource_unreachable',
},
};
mockPost.mockRejectedValue(error503);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await expect(checkDashboardCompatibility(dashboard, mappings)).rejects.toEqual(error503);
});
it('should handle HTTP 502 error (invalid Prometheus API response)', async () => {
const error502 = {
status: 502,
data: {
message: 'Invalid response from Prometheus API',
code: 'api_invalid_response',
},
};
mockPost.mockRejectedValue(error502);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await expect(checkDashboardCompatibility(dashboard, mappings)).rejects.toEqual(error502);
});
it('should handle network error without structured error data', async () => {
const networkError = {
message: 'Network request failed',
};
mockPost.mockRejectedValue(networkError);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await expect(checkDashboardCompatibility(dashboard, mappings)).rejects.toEqual(networkError);
});
it('should use namespace from getAPINamespace()', async () => {
const mockResponse: CompatibilityCheckResult = {
compatibilityScore: 100,
datasourceResults: [],
};
mockPost.mockResolvedValue(mockResponse);
// Change namespace returned by getAPINamespace
mockGetAPINamespace.mockReturnValue('custom-namespace');
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await checkDashboardCompatibility(dashboard, mappings);
expect(mockPost).toHaveBeenCalledWith(
'/apis/dashvalidator.grafana.app/v1alpha1/namespaces/custom-namespace/check',
expect.any(Object),
expect.any(Object)
);
// Reset namespace for other tests
mockGetAPINamespace.mockReturnValue('default');
});
it('should handle generic error without proper structure', async () => {
const genericError = 'Something went wrong';
mockPost.mockRejectedValue(genericError);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await expect(checkDashboardCompatibility(dashboard, mappings)).rejects.toEqual(genericError);
});
it('should disable automatic error alerts', async () => {
const mockResponse: CompatibilityCheckResult = {
compatibilityScore: 100,
datasourceResults: [],
};
mockPost.mockResolvedValue(mockResponse);
const dashboard = createMockDashboard();
const mappings = createMockDatasourceMappings();
await checkDashboardCompatibility(dashboard, mappings);
// Verify that showErrorAlert is explicitly set to false
expect(mockPost).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
expect.objectContaining({
showErrorAlert: false,
})
);
});
});
});
@@ -0,0 +1,146 @@
import { getAPINamespace } from '@grafana/api-clients';
import { getBackendSrv } from '@grafana/runtime';
import { DashboardJson } from 'app/features/manage-dashboards/types';
/**
* Represents a datasource mapping for compatibility checking.
* Maps dashboard datasource references to actual datasource instances.
*/
export interface DatasourceMapping {
/** Unique identifier of the datasource */
uid: string;
/** Type of datasource (e.g., 'prometheus', 'loki') */
type: string;
/** Optional human-readable name for display */
name?: string;
}
/**
* Request body for dashboard compatibility check API call
*/
export interface CheckCompatibilityRequest {
/** Complete dashboard JSON object (supports both v1 and v2 schemas) */
dashboardJson: DashboardJson;
/** Array of datasource mappings to check compatibility against */
datasourceMappings: DatasourceMapping[];
}
/**
* Breakdown of compatibility metrics for a single query within a panel
*/
export interface QueryBreakdown {
/** Title of the panel containing this query */
panelTitle: string;
/** Numeric ID of the panel */
panelID: number;
/** Query reference ID (e.g., 'A', 'B', 'C') */
queryRefId: string;
/** Total number of metrics extracted from this query */
totalMetrics: number;
/** Number of metrics found in the datasource */
foundMetrics: number;
/** List of metric names that were not found */
missingMetrics: string[];
/** Compatibility score for this query (0-100) */
compatibilityScore: number;
/** Optional error message for queries that failed to parse */
parseError?: string;
}
/**
* Compatibility check result for a single datasource
*/
export interface DatasourceResult {
/** Unique identifier of the datasource */
uid: string;
/** Type of datasource */
type: string;
/** Optional human-readable name */
name?: string;
/** Total number of queries in the dashboard */
totalQueries: number;
/** Number of queries that were checked */
checkedQueries: number;
/** Total number of unique metrics extracted from all queries */
totalMetrics: number;
/** Number of metrics found in the datasource */
foundMetrics: number;
/** List of all missing metric names across all queries */
missingMetrics: string[];
/** Overall compatibility score for this datasource (0-100) */
compatibilityScore: number;
/** Detailed breakdown of compatibility per query */
queryBreakdown: QueryBreakdown[];
}
/**
* Overall compatibility check result
*/
export interface CompatibilityCheckResult {
/** Overall compatibility score across all datasources (0-100) */
compatibilityScore: number;
/** Results for each datasource checked */
datasourceResults: DatasourceResult[];
}
/**
* Checks dashboard compatibility with specified datasources.
*
* This function sends the dashboard JSON and datasource mappings to the backend
* validation service, which extracts metrics from dashboard queries and checks
* if those metrics exist in the target datasource(s).
*
* Note: The backend currently only supports v1 dashboards (with panels array).
* V2 dashboards (with elements) will be rejected by the backend with an appropriate error.
*
* @param dashboardJson Complete dashboard JSON object (v1 or v2 schema)
* @param datasourceMappings Array of datasource mappings to validate against
* @returns Promise resolving to compatibility check results
* @throws Error if the API call fails or dashboard schema is unsupported
*
* @example
* ```typescript
* const result = await checkDashboardCompatibility(
* { panels: [...], title: "My Dashboard" },
* [{ uid: "prometheus-uid", type: "prometheus" }]
* );
*
* console.log(`Compatibility: ${result.compatibilityScore}%`);
* console.log(`Missing metrics: ${result.datasourceResults[0].missingMetrics}`);
* ```
*/
export async function checkDashboardCompatibility(
dashboardJson: DashboardJson,
datasourceMappings: DatasourceMapping[]
): Promise<CompatibilityCheckResult> {
// Get namespace from global config (typically 'default' in development)
// This follows Kubernetes API convention for Grafana app plugins
const namespace = getAPINamespace();
// Build request body matching backend schema
const requestBody: CheckCompatibilityRequest = {
dashboardJson,
datasourceMappings,
};
try {
// Make POST request to the dashboard validator app's /check endpoint
// Following Kubernetes API path convention: /apis/{group}/{version}/namespaces/{namespace}/{resource}
const response = await getBackendSrv().post<CompatibilityCheckResult>(
`/apis/dashvalidator.grafana.app/v1alpha1/namespaces/${namespace}/check`,
requestBody,
{
// Disable automatic error alerts - we'll handle errors in the UI
showErrorAlert: false,
}
);
return response;
} catch (error) {
// Log error for debugging
console.error('Dashboard compatibility check failed:', error);
// Re-throw original error for caller to handle
throw error;
}
}
@@ -105,6 +105,29 @@ export const DashboardLibraryInteractions = {
entryPointClicked: (properties: { entryPoint: SourceEntryPoint; contentKind: ContentKind }) => {
reportDashboardLibraryInteraction('entry_point_clicked', properties);
},
compatibilityCheckTriggered: (properties: {
dashboardId: string;
dashboardTitle: string;
datasourceType: string;
triggerMethod: 'manual' | 'auto_initial_load';
eventLocation: EventLocation;
}) => {
reportDashboardLibraryInteraction('compatibility_check_triggered', properties);
},
compatibilityCheckCompleted: (properties: {
dashboardId: string;
dashboardTitle: string;
datasourceType: string;
score: number;
metricsFound: number;
metricsTotal: number;
triggerMethod: 'manual' | 'auto_initial_load';
eventLocation: EventLocation;
}) => {
reportDashboardLibraryInteraction('compatibility_check_completed', properties);
},
};
const reportDashboardLibraryInteraction = (name: string, properties?: Record<string, unknown>) => {
@@ -1,4 +1,5 @@
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { PluginDashboard } from 'app/types/plugins';
export interface Link {
rel: string;
@@ -47,3 +48,11 @@ export interface GnetDashboardsResponse {
pages: number;
items: GnetDashboard[];
}
/**
* Type guard to check if a dashboard is a GnetDashboard (community dashboard).
* PluginDashboard has fields like importedRevision, importedUri, path that GnetDashboard doesn't have.
*/
export function isGnetDashboard(dashboard: PluginDashboard | GnetDashboard): dashboard is GnetDashboard {
return !('importedRevision' in dashboard || 'importedUri' in dashboard || 'path' in dashboard);
}
@@ -1,4 +1,4 @@
import { locationService } from '@grafana/runtime';
import { BackendSrv, getBackendSrv, locationService } from '@grafana/runtime';
import { InputType, DataSourceInput, DashboardInput, DashboardJson } from 'app/features/manage-dashboards/types';
import { DASHBOARD_LIBRARY_ROUTES } from '../../types';
@@ -13,6 +13,7 @@ import {
getLogoUrl,
navigateToTemplate,
onUseCommunityDashboard,
interpolateDashboardForCompatibilityCheck,
} from './communityDashboardHelpers';
jest.mock('../api/dashboardLibraryApi', () => ({
@@ -33,12 +34,34 @@ jest.mock('../interactions', () => ({
},
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: jest.fn(),
locationService: {
push: jest.fn(),
},
}));
// Mock function references
const mockFetchCommunityDashboard = fetchCommunityDashboard as jest.MockedFunction<typeof fetchCommunityDashboard>;
const mockTryAutoMapDatasources = tryAutoMapDatasources as jest.MockedFunction<typeof tryAutoMapDatasources>;
const mockParseConstantInputs = parseConstantInputs as jest.MockedFunction<typeof parseConstantInputs>;
const mockGetBackendSrv = getBackendSrv as jest.MockedFunction<typeof getBackendSrv>;
// Helper functions for creating mock objects
const createMockBackendSrv = (overrides: Partial<BackendSrv> = {}): BackendSrv =>
({
post: jest.fn(),
get: jest.fn(),
delete: jest.fn(),
patch: jest.fn(),
put: jest.fn(),
request: jest.fn(),
datasourceRequest: jest.fn(),
resolveCancelerIfExists: jest.fn(),
...overrides,
}) as BackendSrv;
const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
id: 123,
name: 'Test Dashboard',
@@ -609,4 +632,125 @@ describe('communityDashboardHelpers', () => {
});
});
});
describe('interpolateDashboardForCompatibilityCheck', () => {
let mockPost: jest.Mock;
beforeEach(() => {
mockPost = jest.fn();
mockGetBackendSrv.mockReturnValue(createMockBackendSrv({ post: mockPost }));
});
afterEach(() => {
jest.clearAllMocks();
});
it('should successfully interpolate dashboard when auto-mapping succeeds', async () => {
const dashboardJson = createMockDashboardJson({
__inputs: [
{
name: 'DS_PROMETHEUS',
type: InputType.DataSource,
label: 'Prometheus',
value: '',
description: '',
pluginId: 'prometheus',
info: '',
} as DataSourceInput & { description: string },
],
});
const interpolatedDashboard = createMockDashboardJson({ title: 'Interpolated Dashboard' });
mockFetchCommunityDashboard.mockResolvedValue({ json: dashboardJson });
mockTryAutoMapDatasources.mockReturnValue({
allMapped: true,
mappings: [{ name: 'DS_PROMETHEUS', type: 'datasource', value: 'prom-uid', pluginId: 'prometheus' }],
unmappedDsInputs: [],
});
mockPost.mockResolvedValue(interpolatedDashboard);
const result = await interpolateDashboardForCompatibilityCheck(123, 'prom-uid');
expect(result).toEqual(interpolatedDashboard);
expect(mockFetchCommunityDashboard).toHaveBeenCalledWith(123);
expect(mockTryAutoMapDatasources).toHaveBeenCalled();
expect(mockPost).toHaveBeenCalledWith('/api/dashboards/interpolate', {
dashboard: dashboardJson,
overwrite: true,
inputs: [{ name: 'DS_PROMETHEUS', type: 'datasource', value: 'prom-uid', pluginId: 'prometheus' }],
});
});
it('should throw error when auto-mapping fails', async () => {
const dashboardJson = createMockDashboardJson({
__inputs: [
{
name: 'DS_PROMETHEUS',
type: InputType.DataSource,
label: 'Prometheus',
value: '',
description: '',
pluginId: 'prometheus',
info: '',
} as DataSourceInput & { description: string },
],
});
mockFetchCommunityDashboard.mockResolvedValue({ json: dashboardJson });
mockTryAutoMapDatasources.mockReturnValue({
allMapped: false,
mappings: [],
unmappedDsInputs: [
{
name: 'DS_PROMETHEUS',
pluginId: 'prometheus',
type: InputType.DataSource,
value: '',
label: 'Prometheus',
description: '',
info: '',
},
],
});
await expect(interpolateDashboardForCompatibilityCheck(123, 'prom-uid')).rejects.toThrow(
'Unable to automatically map all datasource inputs for this dashboard'
);
expect(mockPost).not.toHaveBeenCalled();
});
it('should throw error when interpolation API fails', async () => {
const dashboardJson = createMockDashboardJson();
mockFetchCommunityDashboard.mockResolvedValue({ json: dashboardJson });
mockTryAutoMapDatasources.mockReturnValue({
allMapped: true,
mappings: [],
unmappedDsInputs: [],
});
mockPost.mockRejectedValue(new Error('API failed'));
await expect(interpolateDashboardForCompatibilityCheck(123, 'prom-uid')).rejects.toThrow('API failed');
});
it('should handle dashboard with no __inputs', async () => {
const dashboardJson = createMockDashboardJson({ __inputs: undefined });
const interpolatedDashboard = createMockDashboardJson({ title: 'Interpolated Dashboard' });
mockFetchCommunityDashboard.mockResolvedValue({ json: dashboardJson });
mockTryAutoMapDatasources.mockReturnValue({
allMapped: true,
mappings: [],
unmappedDsInputs: [],
});
mockPost.mockResolvedValue(interpolatedDashboard);
const result = await interpolateDashboardForCompatibilityCheck(123, 'prom-uid');
expect(result).toEqual(interpolatedDashboard);
expect(mockTryAutoMapDatasources).toHaveBeenCalledWith([], 'prom-uid');
});
});
});
@@ -1,6 +1,6 @@
import { PanelModel } from '@grafana/data';
import { t } from '@grafana/i18n';
import { locationService } from '@grafana/runtime';
import { getBackendSrv, locationService } from '@grafana/runtime';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { notifyApp } from 'app/core/reducers/appNotification';
import { DataSourceInput, DashboardJson } from 'app/features/manage-dashboards/types';
@@ -310,3 +310,52 @@ export async function onUseCommunityDashboard({
throw err;
}
}
/**
* Interpolate a community dashboard for compatibility checking.
*
* This function fetches the dashboard from Grafana.com, auto-maps datasource inputs,
* and returns the interpolated dashboard with template variables resolved.
*
* @throws Error if auto-mapping fails - compatibility check requires all datasource inputs to be resolved
* @param dashboardId - The Grafana.com dashboard ID
* @param datasourceUid - The UID of the datasource to map to
* @returns Promise<DashboardJson> - The interpolated dashboard with resolved template variables
*/
export async function interpolateDashboardForCompatibilityCheck(
dashboardId: number,
datasourceUid: string
): Promise<DashboardJson> {
// 1. Fetch full dashboard JSON from Grafana.com
const gnetResponse = await fetchCommunityDashboard(dashboardId);
const dashboardJson = gnetResponse.json;
// 2. Extract datasource inputs from dashboard's __inputs array
const dsInputs: DataSourceInput[] = dashboardJson.__inputs?.filter(isDataSourceInput) || [];
// 3. Auto-map datasources using existing utility
const mappingResult = tryAutoMapDatasources(dsInputs, datasourceUid);
// 4. Check if auto-mapping was successful
// Compatibility check requires all datasource variables to be resolved
if (!mappingResult.allMapped) {
throw new Error(
t(
'dashboard-library.compatibility-auto-map-failed',
'Unable to automatically map all datasource inputs for this dashboard. Compatibility check requires all datasource variables to be resolved.'
)
);
}
// 5. Prepare inputs array for interpolation API
const inputs: InputMapping[] = mappingResult.mappings;
// 6. Call interpolation endpoint to replace template variables
const interpolatedDashboard = await getBackendSrv().post<DashboardJson>('/api/dashboards/interpolate', {
dashboard: dashboardJson,
overwrite: true,
inputs: inputs,
});
return interpolatedDashboard;
}
+19 -6
View File
@@ -935,9 +935,9 @@
"parse-mode-warning-body": "If you use a <1>parse_mode</1> option other than <3>None</3>, truncation may result in an invalid message, causing the notification to fail. For longer messages, we recommend using an alternative contact method.",
"parse-mode-warning-title": "Telegram messages are limited to 4096 UTF-8 characters."
},
"used-by_one": "Used by {{count}} notification policy",
"used-by_one": "Used by {{count}} notification policies",
"used-by_other": "Used by {{count}} notification policies",
"used-by-rules_one": "Used by {{count}} alert rule",
"used-by-rules_one": "Used by {{count}} alert rules",
"used-by-rules_other": "Used by {{count}} alert rules"
},
"contact-points-filter": {
@@ -4160,11 +4160,11 @@
},
"restore": {
"all-failed_one": "Failed to restore {{count}} dashboard.",
"all-failed_other": "Failed to restore {{count}} dashboards.",
"all-failed_other": "Failed to restore {{count}} dashboard.",
"failed-count_one": "{{count}} dashboard failed",
"failed-count_other": "{{count}} dashboards failed",
"failed-count_other": "{{count}} dashboard failed",
"success-count_one": "{{count}} dashboard restored successfully",
"success-count_other": "{{count}} dashboards restored successfully",
"success-count_other": "{{count}} dashboard restored successfully",
"success-multiple": "Dashboards restored",
"success-single": "Dashboard restored",
"view-dashboard": "View dashboard",
@@ -6254,6 +6254,19 @@
"community-mapping-select-datasource": "Select a datasource",
"community-search-placeholder": "Search community dashboards...",
"community-search-placeholder-with-datasource": "Search {{datasourceType}} community dashboards...",
"compatibility-auto-map-failed": "Unable to automatically map all datasource inputs for this dashboard. Compatibility check requires all datasource variables to be resolved.",
"compatibility-badge": {
"check": "Check compatibility",
"check-tooltip": "Checks how many dashboard metrics match your data source.",
"checking": "Checking",
"error-text": "Error",
"error-tooltip": "Compatibility check failed. First, verify the <2>data source</2> is working. Then open the dashboard and review the <6>variables</6>.",
"not-supported-tooltip": "This dashboard or datasource type is not yet supported. Only Prometheus datasources and non-dynamic dashboards (v1) are currently supported.",
"score-text": "{{score}}% compatible",
"tooltip-green": "{{score}}% ({{found}}/{{total}}) of metrics match.",
"tooltip-orange": "{{score}}% ({{found}}/{{total}}) of metrics match. This dashboard may contain panels that require heavy customization.",
"tooltip-red": "{{score}}% ({{found}}/{{total}}) of metrics match. This dashboard will require heavy query customization."
},
"dashboard-card": {
"details": {
"datasource": "Datasource",
@@ -10339,7 +10352,7 @@
"loading-panel-text": "Loading library panel",
"modal": {
"body_one": "This panel is being used in {{count}} dashboard. Please choose which dashboard to view the panel in:",
"body_other": "This panel is being used in {{count}} dashboards. Please choose which dashboard to view the panel in:",
"body_other": "This panel is being used in {{count}} dashboard. Please choose which dashboard to view the panel in:",
"button-cancel": "Cancel",
"button-view-panel1": "View panel in {{label}}...",
"button-view-panel2": "View panel in dashboard...",