mirror of
https://github.com/grafana/grafana.git
synced 2026-08-08 20:28:14 -05:00
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:
+107
-15
@@ -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
|
||||
)
|
||||
|
||||
+912
-4
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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Generated
+3
@@ -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 => ({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Generated
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
+369
-6
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+128
-3
@@ -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);
|
||||
}
|
||||
|
||||
+145
-1
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+50
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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...",
|
||||
|
||||
Reference in New Issue
Block a user