From 95f04c79cd5176a722d528ee942107084343469e Mon Sep 17 00:00:00 2001 From: Marco de Abreu Date: Fri, 11 Apr 2025 18:52:46 +0200 Subject: [PATCH] Dashboards: Add Dashboard Schema validation (1) (#103662) --- Makefile | 7 + apps/dashboard/Makefile | 7 + apps/dashboard/go.mod | 4 + apps/dashboard/go.sum | 22 + .../dashboard/v0alpha1/dashboard_kind.cue | 759 ++++++++++++++ .../pkg/apis/dashboard/v0alpha1/validation.go | 90 ++ .../dashboard/v1alpha1/dashboard_kind.cue | 759 ++++++++++++++ .../pkg/apis/dashboard/v1alpha1/validation.go | 90 ++ .../dashboard/v2alpha1/dashboard_spec.cue | 965 ++++++++++++++++++ .../pkg/apis/dashboard/v2alpha1/validation.go | 84 ++ devenv/dev-dashboards/all-panels.json | 2 +- .../feature-toggles/index.md | 3 + .../src/types/featureToggles.gen.ts | 12 + pkg/registry/apis/dashboard/large_test.go | 2 +- pkg/registry/apis/dashboard/mutate.go | 56 +- pkg/registry/apis/dashboard/mutation_test.go | 12 +- .../apis/dashboard/schema_validation.go | 78 ++ pkg/services/apiserver/client/client.go | 12 +- pkg/services/apiserver/client/client_mock.go | 8 +- .../dashboards/service/dashboard_service.go | 16 +- .../service/dashboard_service_test.go | 20 +- pkg/services/featuremgmt/registry.go | 18 + pkg/services/featuremgmt/toggles_gen.csv | 3 + pkg/services/featuremgmt/toggles_gen.go | 12 + pkg/services/featuremgmt/toggles_gen.json | 62 ++ .../folder/folderimpl/unifiedstore.go | 8 +- .../testdata/devdash-all-panels-info.json | 2 +- pkg/tests/apis/dashboard/dashboards_test.go | 3 +- .../integration/api_validation_test.go | 3 +- .../dashboard/testdata/dashboard-test-v1.yaml | 1 + 30 files changed, 3086 insertions(+), 34 deletions(-) create mode 100644 apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue create mode 100644 apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go create mode 100644 apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue create mode 100644 apps/dashboard/pkg/apis/dashboard/v1alpha1/validation.go create mode 100644 apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue create mode 100644 apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go create mode 100644 pkg/registry/apis/dashboard/schema_validation.go diff --git a/Makefile b/Makefile index dd40b492aeb..db63469af41 100644 --- a/Makefile +++ b/Makefile @@ -148,6 +148,13 @@ gen-cue: ## Do all CUE/Thema code generation @echo "generate code from .cue files" go generate ./kinds/gen.go go generate ./public/app/plugins/gen.go + @echo "// This file is managed by Grafana - DO NOT EDIT MANUALLY" > apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue + @echo "// Source: kinds/dashboard/dashboard_kind.cue" >> apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue + @echo "// To sync changes, run: make gen-cue" >> apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue + @echo "" >> apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue + @cat kinds/dashboard/dashboard_kind.cue >> apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue + @cp apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue + .PHONY: gen-cuev2 gen-cuev2: ## Do all CUE code generation diff --git a/apps/dashboard/Makefile b/apps/dashboard/Makefile index 63f6212fd13..3a05e034c79 100644 --- a/apps/dashboard/Makefile +++ b/apps/dashboard/Makefile @@ -53,3 +53,10 @@ post-generate-cleanup: ## Clean up the generated code @sed -e '/\/\/ DeepCopyInto deep copies Spec into another Spec object/,+3d' ./pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go.tmp > ./pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go.tmp2 @rm ./pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go.tmp @mv ./pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go.tmp2 ./pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go + + # Copy dashboard/v2alpha1 spec so we can use it for schema validation + @echo "// This file is managed by grafana-app-sdk - DO NOT EDIT MANUALLY" > ./pkg/apis/dashboard/v2alpha1/dashboard_spec.cue + @echo "// Source: apps/dashboard/kinds/v2alpha1/dashboard_spec.cue" >> ./pkg/apis/dashboard/v2alpha1/dashboard_spec.cue + @echo "// To sync changes, run: make generate in apps/dashboard" >> ./pkg/apis/dashboard/v2alpha1/dashboard_spec.cue + @echo "" >> ./pkg/apis/dashboard/v2alpha1/dashboard_spec.cue + @cat ./kinds/v2alpha1/dashboard_spec.cue >> ./pkg/apis/dashboard/v2alpha1/dashboard_spec.cue diff --git a/apps/dashboard/go.mod b/apps/dashboard/go.mod index 39a2480a4c9..24a75eb9446 100644 --- a/apps/dashboard/go.mod +++ b/apps/dashboard/go.mod @@ -3,6 +3,7 @@ module github.com/grafana/grafana/apps/dashboard go 1.24.2 require ( + cuelang.org/go v0.11.1 github.com/grafana/grafana-app-sdk v0.35.1 github.com/grafana/grafana-plugin-sdk-go v0.274.1-0.20250318081012-21a7f15619b0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250312121619-f64be062c432 @@ -19,6 +20,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect github.com/chromedp/cdproto v0.0.0-20240810084448-b931b754e476 // indirect + github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/elazarl/goproxy v1.7.2 // indirect @@ -55,6 +57,7 @@ require ( github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/magefile/mage v1.15.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattetti/filebuffer v1.0.1 // indirect @@ -69,6 +72,7 @@ require ( github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/oklog/run v1.1.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/apps/dashboard/go.sum b/apps/dashboard/go.sum index 04c94943cc1..b13e97d0eaa 100644 --- a/apps/dashboard/go.sum +++ b/apps/dashboard/go.sum @@ -1,3 +1,7 @@ +cuelabs.dev/go/oci/ociregistry v0.0.0-20240906074133-82eb438dd565 h1:R5wwEcbEZSBmeyg91MJZTxfd7WpBo2jPof3AYjRbxwY= +cuelabs.dev/go/oci/ociregistry v0.0.0-20240906074133-82eb438dd565/go.mod h1:5A4xfTzHTXfeVJBU6RAUf+QrlfTCW+017q/QiW+sMLg= +cuelang.org/go v0.11.1 h1:pV+49MX1mmvDm8Qh3Za3M786cty8VKPWzQ1Ho4gZRP0= +cuelang.org/go v0.11.1/go.mod h1:PBY6XvPUswPPJ2inpvUozP9mebDVTXaeehQikhZPBz0= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= @@ -20,6 +24,8 @@ github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitf github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= github.com/chromedp/cdproto v0.0.0-20240810084448-b931b754e476 h1:VnjHsRXCRti7Av7E+j4DCha3kf68echfDzQ+wD11SBU= github.com/chromedp/cdproto v0.0.0-20240810084448-b931b754e476/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= +github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= @@ -32,6 +38,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/proto v1.13.2 h1:z/etSFO3uyXeuEsVPzfl56WNgzcvIr42aQazXaQmFZY= +github.com/emicklei/proto v1.13.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= @@ -56,6 +64,8 @@ github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= @@ -133,6 +143,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -154,6 +166,8 @@ github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpsp github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -173,6 +187,12 @@ github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= @@ -190,6 +210,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/protocolbuffers/txtpbfmt v0.0.0-20241112170944-20d2c9ebc01d h1:HWfigq7lB31IeJL8iy7jkUmU/PG1Sr8jVGhS749dbUA= +github.com/protocolbuffers/txtpbfmt v0.0.0-20241112170944-20d2c9ebc01d/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue new file mode 100644 index 00000000000..b9fd38146b7 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue @@ -0,0 +1,759 @@ +// This file is managed by Grafana - DO NOT EDIT MANUALLY +// Source: kinds/dashboard/dashboard_kind.cue +// To sync changes, run: make gen-cue + +package kind + +import ( + "strings" + t "time" +) + +name: "Dashboard" +maturity: "experimental" +description: "A Grafana dashboard." + +crd: dummySchema: true + +lineage: schemas: [{ + version: [0, 0] + schema: { + spec: { + // Unique numeric identifier for the dashboard. + // `id` is internal to a specific Grafana instance. `uid` should be used to identify a dashboard across Grafana instances. + id?: int64 | null // TODO eliminate this null option + + // Unique dashboard identifier that can be generated by anyone. string (8-40) + uid?: string + + // Title of dashboard. + title?: string + + // Description of dashboard. + description?: string + + // This property should only be used in dashboards defined by plugins. It is a quick check + // to see if the version has changed since the last time. + revision?: int64 + + // ID of a dashboard imported from the https://grafana.com/grafana/dashboards/ portal + gnetId?: string + + // Tags associated with dashboard. + tags?: [...string] + + // Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". + timezone?: string | *"browser" + + // Whether a dashboard is editable or not. + editable?: bool | *true + + // Configuration of dashboard cursor sync behavior. + // Accepted values are 0 (sync turned off), 1 (shared crosshair), 2 (shared crosshair and tooltip). + graphTooltip?: #DashboardCursorSync + + // Time range for dashboard. + // Accepted values are relative time strings like {from: 'now-6h', to: 'now'} or absolute time strings like {from: '2020-07-10T08:00:00.000Z', to: '2020-07-10T14:00:00.000Z'}. + time?: { + from: string | *"now-6h" + to: string | *"now" + } + + // Configuration of the time picker shown at the top of a dashboard. + timepicker?: #TimePickerConfig + + // The month that the fiscal year starts on. 0 = January, 11 = December + fiscalYearStartMonth?: uint8 & <12 | *0 + + // When set to true, the dashboard will redraw panels at an interval matching the pixel width. + // This will keep data "moving left" regardless of the query refresh rate. This setting helps + // avoid dashboards presenting stale live data + liveNow?: bool + + // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". + weekStart?: string + + // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". + refresh?: string + + // Version of the JSON schema, incremented each time a Grafana update brings + // changes to said schema. + schemaVersion: uint16 | *41 + + // Version of the dashboard, incremented each time the dashboard is updated. + version?: uint32 + + // List of dashboard panels + panels?: [...(#Panel | #RowPanel)] + + // Configured template variables + templating?: { + // List of configured template variables with their saved values along with some other metadata + list?: [...#VariableModel] + } + + // Contains the list of annotations that are associated with the dashboard. + // Annotations are used to overlay event markers and overlay event tags on graphs. + // Grafana comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the HTTP API. + // See https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/ + annotations?: #AnnotationContainer + + // Links with references to other dashboards or external websites. + links?: [...#DashboardLink] + + // Snapshot options. They are present only if the dashboard is a snapshot. + snapshot?: #Snapshot @grafanamaturity(NeedsExpertReview) + + // When set to true, the dashboard will load all panels in the dashboard when it's loaded. + preload?: bool + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + /////////////////////////////////////// + // Definitions (referenced above) are declared below + + // TODO: this should be a regular DataQuery that depends on the selected dashboard + // these match the properties of the "grafana" datasouce that is default in most dashboards + #AnnotationTarget: { + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + limit: int64 + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + matchAny: bool + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + tags: [...string] + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + type: string + ... // datasource will stick their raw DataQuery here + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + #AnnotationPanelFilter: { + // Should the specified panels be included or excluded + exclude?: bool | *false + + // Panel IDs that should be included or excluded + ids: [...uint8] + } @cuetsy(kind="interface") + + // Contains the list of annotations that are associated with the dashboard. + // Annotations are used to overlay event markers and overlay event tags on graphs. + // Grafana comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the HTTP API. + // See https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/ + #AnnotationContainer: { + // List of annotations + list?: [...#AnnotationQuery] + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // TODO docs + // FROM: AnnotationQuery in grafana-data/src/types/annotations.ts + #AnnotationQuery: { + // Name of annotation. + name: string + + // Datasource where the annotations data is + datasource: #DataSourceRef + + // When enabled the annotation query is issued with every dashboard refresh + enable: bool | *true + + // Annotation queries can be toggled on or off at the top of the dashboard. + // When hide is true, the toggle is not shown in the dashboard. + hide?: bool | *false + + // Color to use for the annotation event markers + iconColor: string + + // Filters to apply when fetching annotations + filter?: #AnnotationPanelFilter + + // TODO.. this should just be a normal query target + target?: #AnnotationTarget + + // TODO -- this should not exist here, it is based on the --grafana-- datasource + type?: string @grafanamaturity(NeedsExpertReview) + + // Set to 1 for the standard annotation query all dashboards have by default. + builtIn?: number | *0 + + // unless datasources have migrated to the target+mapping, + // they just spread their query into the base object :( + ... + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // A variable is a placeholder for a value. You can use variables in metric queries and in panel titles. + #VariableModel: { + // Type of variable + type: #VariableType + // Name of variable + name: string + // Optional display name + label?: string + // Visibility configuration for the variable + hide?: #VariableHide + // Whether the variable value should be managed by URL query params or not + skipUrlSync?: bool | *false + // Description of variable. It can be defined but `null`. + description?: string + // Query used to fetch values for a variable + query?: string | {...} + // Data source used to fetch values for a variable. It can be defined but `null`. + datasource?: #DataSourceRef + // Shows current selected variable text/value on the dashboard + current?: #VariableOption + // Whether multiple values can be selected or not from variable value list + multi?: bool | *false + // Allow custom values to be entered in the variable + allowCustomValue?: bool | *true + // Options that can be selected for a variable. + options?: [...#VariableOption] + // Options to config when to refresh a variable + refresh?: #VariableRefresh + // Options sort order + sort?: #VariableSort + // Whether all value option is available or not + includeAll?: bool | *false + // Custom all value + allValue?: string + // Optional field, if you want to extract part of a series name or metric node segment. + // Named capture groups can be used to separate the display text and value. + regex?: string + ... + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // Option to be selected in a variable. + #VariableOption: { + // Whether the option is selected or not + selected?: bool + // Text to be displayed for the option + text: string | [...string] + // Value of the option + value: string | [...string] + } @cuetsy(kind="interface") + + // Options to config when to refresh a variable + // `0`: Never refresh the variable + // `1`: Queries the data source every time the dashboard loads. + // `2`: Queries the data source when the dashboard time range changes. + #VariableRefresh: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="never|onDashboardLoad|onTimeRangeChanged") + + // Determine if the variable shows on dashboard + // Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing). + #VariableHide: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable") @grafana(TSVeneer="type") + + // Sort variable options + // Accepted values are: + // `0`: No sorting + // `1`: Alphabetical ASC + // `2`: Alphabetical DESC + // `3`: Numerical ASC + // `4`: Numerical DESC + // `5`: Alphabetical Case Insensitive ASC + // `6`: Alphabetical Case Insensitive DESC + // `7`: Natural ASC + // `8`: Natural DESC + #VariableSort: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 @cuetsy(kind="enum",memberNames="disabled|alphabeticalAsc|alphabeticalDesc|numericalAsc|numericalDesc|alphabeticalCaseInsensitiveAsc|alphabeticalCaseInsensitiveDesc|naturalAsc|naturalDesc") + + // Ref to a DataSource instance + #DataSourceRef: { + // The plugin type-id + type?: string + + // Specific datasource instance + uid?: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // Links with references to other dashboards or external resources + #DashboardLink: { + // Title to display with the link + title: string + // Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) + type: #DashboardLinkType + // Icon name to be displayed with the link + icon: string + // Tooltip to display when the user hovers their mouse over it + tooltip: string + // Link URL. Only required/valid if the type is link + url?: string + // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards + tags: [...string] + // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards + asDropdown: bool | *false + // If true, the link will be opened in a new tab + targetBlank: bool | *false + // If true, includes current template variables values in the link as query params + includeVars: bool | *false + // If true, includes current time range in the link as query params + keepTime: bool | *false + } @cuetsy(kind="interface") + + // Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) + #DashboardLinkType: "link" | "dashboards" @cuetsy(kind="type") + + // Dashboard variable type + // `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. + // `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only). + // `constant`: Define a hidden constant. + // `datasource`: Quickly change the data source for an entire dashboard. + // `interval`: Interval variables represent time spans. + // `textbox`: Display a free text input field with an optional default value. + // `custom`: Define the variable options manually using a comma-separated list. + // `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables + #VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | + "system" | "snapshot" @cuetsy(kind="type") @grafanamaturity(NeedsExpertReview) + + // Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value. + // Continuous color interpolates a color using the percentage of a value relative to min and max. + // Accepted values are: + // `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold + // `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations + // `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations + // `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode + // `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode + // `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode + // `continuous-YlRd`: Continuous Yellow-Red palette mode + // `continuous-BlPu`: Continuous Blue-Purple palette mode + // `continuous-YlBl`: Continuous Yellow-Blue palette mode + // `continuous-blues`: Continuous Blue palette mode + // `continuous-reds`: Continuous Red palette mode + // `continuous-greens`: Continuous Green palette mode + // `continuous-purples`: Continuous Purple palette mode + // `shades`: Shades of a single color. Specify a single color, useful in an override rule. + // `fixed`: Fixed color mode. Specify a single color, useful in an override rule. + #FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades" @cuetsy(kind="enum",memberNames="Thresholds|PaletteClassic|PaletteClassicByName|ContinuousGrYlRd|ContinuousRdYlGr|ContinuousBlYlRd|ContinuousYlRd|ContinuousBlPu|ContinuousYlBl|ContinuousBlues|ContinuousReds|ContinuousGreens|ContinuousPurples|Fixed|Shades") @grafanamaturity(NeedsExpertReview) + + // Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value. + #FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type") + + // Map a field to a color. + #FieldColor: { + // The main color scheme mode. + mode: #FieldColorModeId + // The fixed color value for fixed or shades color modes. + fixedColor?: string + // Some visualizations need to know how to assign a series color from by value color schemes. + seriesBy?: #FieldColorSeriesByMode + } @cuetsy(kind="interface") + + // Position and dimensions of a panel in the grid + #GridPos: { + // Panel height. The height is the number of rows from the top edge of the panel. + h: uint32 & >0 | *9 + // Panel width. The width is the number of columns from the left edge of the panel. + w: uint32 & >0 & <=24 | *12 + // Panel x. The x coordinate is the number of columns from the left edge of the grid + x: uint32 & >=0 & <24 | *0 + // Panel y. The y coordinate is the number of rows from the top edge of the grid + y: uint32 & >=0 | *0 + // Whether the panel is fixed within the grid. If true, the panel will not be affected by other panels' interactions + static?: bool + } @cuetsy(kind="interface") + + // User-defined value for a metric that triggers visual changes in a panel when this value is met or exceeded + // They are used to conditionally style and color visualizations based on query results , and can be applied to most visualizations. + #Threshold: { + // Value represents a specified metric for the threshold, which triggers a visual change in the dashboard when this value is met or exceeded. + // Nulls currently appear here when serializing -Infinity to JSON. + value: number | null @grafanamaturity(NeedsExpertReview) + // Color represents the color of the visual change that will occur in the dashboard when the threshold value is met or exceeded. + color: string @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Thresholds can either be `absolute` (specific number) or `percentage` (relative to min or max, it will be values between 0 and 1). + #ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum",memberNames="Absolute|Percentage") + + // Thresholds configuration for the panel + #ThresholdsConfig: { + // Thresholds mode. + mode: #ThresholdsMode + + // Must be sorted by 'value', first value is always -Infinity + steps: [...#Threshold] @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Allow to transform the visual representation of specific data values in a visualization, irrespective of their original units + #ValueMapping: #ValueMap | #RangeMap | #RegexMap | #SpecialValueMap @cuetsy(kind="type") @grafanamaturity(NeedsExpertReview) + + // Supported value mapping types + // `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. + // `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. + // `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. + // `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A. + #MappingType: "value" | "range" | "regex" | "special" @cuetsy(kind="enum",memberNames="ValueToText|RangeToText|RegexToText|SpecialValue") @grafanamaturity(NeedsExpertReview) + + // Maps text values to a color or different display text and color. + // For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. + #ValueMap: { + type: #MappingType & "value" + // Map with : ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } } + options: [string]: #ValueMappingResult + } @cuetsy(kind="interface") + + // Maps numerical ranges to a display text and color. + // For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. + #RangeMap: { + type: #MappingType & "range" + // Range to match against and the result to apply when the value is within the range + options: { + // Min value of the range. It can be null which means -Infinity + from: float64 | null + // Max value of the range. It can be null which means +Infinity + to: float64 | null + // Config to apply when the value is within the range + result: #ValueMappingResult + } + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Maps regular expressions to replacement text and a color. + // For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. + #RegexMap: { + type: #MappingType & "regex" + // Regular expression to match against and the result to apply when the value matches the regex + options: { + // Regular expression to match against + pattern: string + // Config to apply when the value matches the regex + result: #ValueMappingResult + } + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. + // See SpecialValueMatch to see the list of special values. + // For example, you can configure a special value mapping so that null values appear as N/A. + #SpecialValueMap: { + type: #MappingType & "special" + options: { + // Special value to match against + match: #SpecialValueMatch + // Config to apply when the value matches the special value + result: #ValueMappingResult + } + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Special value types supported by the `SpecialValueMap` + #SpecialValueMatch: "true" | "false" | "null" | "nan" | "null+nan" | "empty" @cuetsy(kind="enum",memberNames="True|False|Null|NaN|NullAndNan|Empty") + + // Result used as replacement with text and color when the value matches + #ValueMappingResult: { + // Text to display when the value matches + text?: string + // Text to use when the value matches + color?: string + // Icon to display when the value matches. Only specific visualizations. + icon?: string + // Position in the mapping array. Only used internally. + index?: int32 + } @cuetsy(kind="interface") + + // Transformations allow to manipulate data returned by a query before the system applies a visualization. + // Using transformations you can: rename fields, join time series data, perform mathematical operations across queries, + // use the output of one transformation as the input to another transformation, etc. + #DataTransformerConfig: { + // Unique identifier of transformer + id: string + // Disabled transformations are skipped + disabled?: bool + // Optional frame matcher. When missing it will be applied to all results + filter?: #MatcherConfig + // Where to pull DataFrames from as input to transformation + topic?: "series" | "annotations" | "alertStates" // replaced with common.DataTopic + // Options to be passed to the transformer + // Valid options depend on the transformer id + options: _ + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // Counterpart for TypeScript's TimeOption type. + #TimeOption: { + display: string + from: string + to: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // Time picker configuration + // It defines the default config for the time picker and the refresh picker for the specific dashboard. + #TimePickerConfig: { + // Whether timepicker is visible or not. + hidden?: bool | *false + // Interval options available in the refresh picker dropdown. + refresh_intervals?: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + // Quick ranges for time picker. + quick_ranges?: [...#TimeOption] + // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. + nowDelay?: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // 0 for no shared crosshair or tooltip (default). + // 1 for shared crosshair. + // 2 for shared crosshair AND shared tooltip. + #DashboardCursorSync: *0 | 1 | 2 @cuetsy(kind="enum",memberNames="Off|Crosshair|Tooltip") + + // Schema for panel targets is specified by datasource + // plugins. We use a placeholder definition, which the Go + // schema loader either left open/as-is with the Base + // variant of the Dashboard and Panel families, or filled + // with types derived from plugins in the Instance variant. + // When working directly from CUE, importers can extend this + // type directly to achieve the same effect. + #Target: {...} + + // A dashboard snapshot shares an interactive dashboard publicly. + // It is a read-only version of a dashboard, and is not editable. + // It is possible to create a snapshot of a snapshot. + // Grafana strips away all sensitive information from the dashboard. + // Sensitive information stripped: queries (metric, template,annotation) and panel links. + #Snapshot: { + // Time when the snapshot was created + created: string & t.Time + // Time when the snapshot expires, default is never to expire + expires: string @grafanamaturity(NeedsExpertReview) + // Is the snapshot saved in an external grafana instance + external: bool @grafanamaturity(NeedsExpertReview) + // external url, if snapshot was shared in external grafana instance + externalUrl: string @grafanamaturity(NeedsExpertReview) + // original url, url of the dashboard that was snapshotted + originalUrl: string @grafanamaturity(NeedsExpertReview) + // Unique identifier of the snapshot + id: uint32 @grafanamaturity(NeedsExpertReview) + // Optional, defined the unique key of the snapshot, required if external is true + key: string @grafanamaturity(NeedsExpertReview) + // Optional, name of the snapshot + name: string @grafanamaturity(NeedsExpertReview) + // org id of the snapshot + orgId: uint32 @grafanamaturity(NeedsExpertReview) + // last time when the snapshot was updated + updated: string & t.Time + // url of the snapshot, if snapshot was shared internally + url?: string @grafanamaturity(NeedsExpertReview) + // user id of the snapshot creator + userId: uint32 @grafanamaturity(NeedsExpertReview) + } @grafanamaturity(NeedsExpertReview) + + // Dashboard panels are the basic visualization building blocks. + #Panel: { + // The panel plugin type id. This is used to find the plugin to display the panel. + type: string & strings.MinRunes(1) + + // Unique identifier of the panel. Generated by Grafana when creating a new panel. It must be unique within a dashboard, but not globally. + id?: uint32 + + // The version of the plugin that is used for this panel. This is used to find the plugin to display the panel and to migrate old panel configs. + pluginVersion?: string + + // Depends on the panel plugin. See the plugin documentation for details. + targets?: [...#Target] + + // Panel title. + title?: string + + // Panel description. + description?: string + + // Whether to display the panel without a background. + transparent?: bool | *false + + // The datasource used in all targets. + datasource?: #DataSourceRef + + // Grid position. + gridPos?: #GridPos + + // Panel links. + links?: [...#DashboardLink] + + // Name of template variable to repeat for. + repeat?: string + + // Direction to repeat in if 'repeat' is set. + // `h` for horizontal, `v` for vertical. + repeatDirection?: *"h" | "v" + + // Option for repeated panels that controls max items per row + // Only relevant for horizontally repeated panels + maxPerRow?: number + + // The maximum number of data points that the panel queries are retrieving. + maxDataPoints?: number + + // List of transformations that are applied to the panel data before rendering. + // When there are multiple transformations, Grafana applies them in the order they are listed. + // Each transformation creates a result set that then passes on to the next transformation in the processing pipeline. + transformations?: [...#DataTransformerConfig] + + // The min time interval setting defines a lower limit for the $__interval and $__interval_ms variables. + // This value must be formatted as a number followed by a valid time + // identifier like: "40s", "3d", etc. + // See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options + interval?: string + + // Overrides the relative time range for individual panels, + // which causes them to be different than what is selected in + // the dashboard time picker in the top-right corner of the dashboard. You can use this to show metrics from different + // time periods or days on the same dashboard. + // The value is formatted as time operation like: `now-5m` (Last 5 minutes), `now/d` (the day so far), + // `now-5d/d`(Last 5 days), `now/w` (This week so far), `now-2y/y` (Last 2 years). + // Note: Panel time overrides have no effect when the dashboard’s time range is absolute. + // See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options + timeFrom?: string + + // Overrides the time range for individual panels by shifting its start and end relative to the time picker. + // For example, you can shift the time range for the panel to be two hours earlier than the dashboard time picker setting `2h`. + // Note: Panel time overrides have no effect when the dashboard’s time range is absolute. + // See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options + timeShift?: string + + // Controls if the timeFrom or timeShift overrides are shown in the panel header + hideTimeOverride?: bool + + // Dynamically load the panel + libraryPanel?: #LibraryPanelRef + + // Sets panel queries cache timeout. + cacheTimeout?: string + + // Overrides the data source configured time-to-live for a query cache item in milliseconds + queryCachingTTL?: number + + // It depends on the panel plugin. They are specified by the Options field in panel plugin schemas. + options?: {...} @grafanamaturity(NeedsExpertReview) + + // Field options allow you to change how the data is displayed in your visualizations. + fieldConfig?: #FieldConfigSource + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. + // Each column within this structure is called a field. A field can represent a single time series or table column. + // Field options allow you to change how the data is displayed in your visualizations. + #FieldConfigSource: { + // Defaults are the options applied to all fields. + defaults: #FieldConfig + // Overrides are the options applied to specific fields overriding the defaults. + overrides: [...{ + matcher: #MatcherConfig + properties: [...#DynamicConfigValue] + }] @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // A library panel is a reusable panel that you can use in any dashboard. + // When you make a change to a library panel, that change propagates to all instances of where the panel is used. + // Library panels streamline reuse of panels across multiple dashboards. + #LibraryPanelRef: { + // Library panel name + name: string + // Library panel uid + uid: string + } @cuetsy(kind="interface") + + // Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. + // It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. + #MatcherConfig: { + // The matcher id. This is used to find the matcher implementation from registry. + id: string | *"" @grafanamaturity(NeedsExpertReview) + // The matcher options. This is specific to the matcher implementation. + options?: _ @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + #DynamicConfigValue: { + id: string | *"" @grafanamaturity(NeedsExpertReview) + value?: _ @grafanamaturity(NeedsExpertReview) + } + + // The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. + // Each column within this structure is called a field. A field can represent a single time series or table column. + // Field options allow you to change how the data is displayed in your visualizations. + #FieldConfig: { + // The display value for this field. This supports template variables blank is auto + displayName?: string @grafanamaturity(NeedsExpertReview) + + // This can be used by data sources that return and explicit naming structure for values and labels + // When this property is configured, this value is used rather than the default naming strategy. + displayNameFromDS?: string @grafanamaturity(NeedsExpertReview) + + // Human readable field metadata + description?: string @grafanamaturity(NeedsExpertReview) + + // An explicit path to the field in the datasource. When the frame meta includes a path, + // This will default to `${frame.meta.path}/${field.name} + // + // When defined, this value can be used as an identifier within the datasource scope, and + // may be used to update the results + path?: string @grafanamaturity(NeedsExpertReview) + + // True if data source can write a value to the path. Auth/authz are supported separately + writeable?: bool @grafanamaturity(NeedsExpertReview) + + // True if data source field supports ad-hoc filters + filterable?: bool @grafanamaturity(NeedsExpertReview) + + // Unit a field should use. The unit you select is applied to all fields except time. + // You can use the units ID availables in Grafana or a custom unit. + // Available units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts + // As custom unit, you can use the following formats: + // `suffix:` for custom unit that should go after value. + // `prefix:` for custom unit that should go before value. + // `time:` For custom date time formats type for example `time:YYYY-MM-DD`. + // `si:` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character. + // `count:` for a custom count unit. + // `currency:` for custom a currency unit. + unit?: string @grafanamaturity(NeedsExpertReview) + + // Specify the number of decimals Grafana includes in the rendered value. + // If you leave this field blank, Grafana automatically truncates the number of decimals based on the value. + // For example 1.1234 will display as 1.12 and 100.456 will display as 100. + // To display all decimals, set the unit to `String`. + decimals?: number @grafanamaturity(NeedsExpertReview) + + // The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + min?: number @grafanamaturity(NeedsExpertReview) + // The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + max?: number @grafanamaturity(NeedsExpertReview) + + // Convert input values into a display string + mappings?: [...#ValueMapping] @grafanamaturity(NeedsExpertReview) + + // Map numeric values to states + thresholds?: #ThresholdsConfig @grafanamaturity(NeedsExpertReview) + + // Panel color configuration + color?: #FieldColor + + // The behavior when clicking on a result + links?: [...] @grafanamaturity(NeedsExpertReview) + + // Alternative to empty string + noValue?: string @grafanamaturity(NeedsExpertReview) + + // custom is specified by the FieldConfig field + // in panel plugin schemas. + custom?: {...} @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // Row panel + #RowPanel: { + // The panel type + type: "row" + + // Whether this row should be collapsed or not. + collapsed: bool | *false + + // Row title + title?: string + + // Name of default datasource for the row + datasource?: #DataSourceRef + + // Row grid position + gridPos?: #GridPos + + // Unique identifier of the panel. Generated by Grafana when creating a new panel. It must be unique within a dashboard, but not globally. + id: uint32 + + // List of panels in the row + panels: [...#Panel] + + // Name of template variable to repeat for. + repeat?: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + } +}, +] diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go new file mode 100644 index 00000000000..6fdf1dd4515 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go @@ -0,0 +1,90 @@ +package v0alpha1 + +import ( + _ "embed" + json "encoding/json" + fmt "fmt" + "strings" + "sync" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" + "k8s.io/apimachinery/pkg/util/validation/field" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/errors" + cuejson "cuelang.org/go/encoding/json" +) + +func ValidateDashboardSpec(obj *Dashboard, forceValidation bool) (field.ErrorList, field.ErrorList) { + var schemaVersionError field.ErrorList + schemaVersion := schemaversion.GetSchemaVersion(obj.Spec.Object) + if schemaVersion != schemaversion.LATEST_VERSION { + schemaVersionError = field.ErrorList{field.Invalid(field.NewPath("spec", "schemaVersion"), field.OmitValueType{}, fmt.Sprintf("Schema version %d is not supported - please upgrade to %d", schemaVersion, schemaversion.LATEST_VERSION))} + if !forceValidation { + return nil, schemaVersionError + } + } + + data, err := json.Marshal(obj.Spec.Object) + if err != nil { + return field.ErrorList{ + field.Invalid(field.NewPath("spec"), field.OmitValueType{}, err.Error()), + }, schemaVersionError + } + + if err := cuejson.Validate(data, getCueSchema()); err != nil { + errs := field.ErrorList{} + + for _, e := range errors.Errors(err) { + if + // We don't want to return confusing "empty disjunction" errors, + // because the users don't necessarily understand what to do with them. + // For empty disjunctions, CUE will also return more specific errors, + // so we can safely ignore the generic ones. + strings.Contains(e.Error(), "disjunction") || + // We don't want to return errors about unknown fields either. + strings.Contains(e.Error(), "field not allowed") { + continue + } + + // We want to manually format the error message, + // because e.Error() contains the full CUE path. + format, args := e.Msg() + + errs = append(errs, field.Invalid( + field.NewPath(formatErrorPath(e.Path())), + field.OmitValueType{}, + fmt.Sprintf(format, args...), + )) + } + + return errs, schemaVersionError + } + + return nil, schemaVersionError +} + +func formatErrorPath(path []string) string { + // omitting the "lineage.schemas[0].schema.spec" prefix here. + return strings.Join(path[4:], ".") +} + +var ( + compiledSchema cue.Value + getSchemaOnce sync.Once +) + +//go:embed dashboard_kind.cue +var schemaSource string + +func getCueSchema() cue.Value { + getSchemaOnce.Do(func() { + cueCtx := cuecontext.New() + compiledSchema = cueCtx.CompileString(schemaSource).LookupPath( + cue.ParsePath("lineage.schemas[0].schema.spec"), + ) + }) + + return compiledSchema +} diff --git a/apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue new file mode 100644 index 00000000000..b9fd38146b7 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_kind.cue @@ -0,0 +1,759 @@ +// This file is managed by Grafana - DO NOT EDIT MANUALLY +// Source: kinds/dashboard/dashboard_kind.cue +// To sync changes, run: make gen-cue + +package kind + +import ( + "strings" + t "time" +) + +name: "Dashboard" +maturity: "experimental" +description: "A Grafana dashboard." + +crd: dummySchema: true + +lineage: schemas: [{ + version: [0, 0] + schema: { + spec: { + // Unique numeric identifier for the dashboard. + // `id` is internal to a specific Grafana instance. `uid` should be used to identify a dashboard across Grafana instances. + id?: int64 | null // TODO eliminate this null option + + // Unique dashboard identifier that can be generated by anyone. string (8-40) + uid?: string + + // Title of dashboard. + title?: string + + // Description of dashboard. + description?: string + + // This property should only be used in dashboards defined by plugins. It is a quick check + // to see if the version has changed since the last time. + revision?: int64 + + // ID of a dashboard imported from the https://grafana.com/grafana/dashboards/ portal + gnetId?: string + + // Tags associated with dashboard. + tags?: [...string] + + // Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". + timezone?: string | *"browser" + + // Whether a dashboard is editable or not. + editable?: bool | *true + + // Configuration of dashboard cursor sync behavior. + // Accepted values are 0 (sync turned off), 1 (shared crosshair), 2 (shared crosshair and tooltip). + graphTooltip?: #DashboardCursorSync + + // Time range for dashboard. + // Accepted values are relative time strings like {from: 'now-6h', to: 'now'} or absolute time strings like {from: '2020-07-10T08:00:00.000Z', to: '2020-07-10T14:00:00.000Z'}. + time?: { + from: string | *"now-6h" + to: string | *"now" + } + + // Configuration of the time picker shown at the top of a dashboard. + timepicker?: #TimePickerConfig + + // The month that the fiscal year starts on. 0 = January, 11 = December + fiscalYearStartMonth?: uint8 & <12 | *0 + + // When set to true, the dashboard will redraw panels at an interval matching the pixel width. + // This will keep data "moving left" regardless of the query refresh rate. This setting helps + // avoid dashboards presenting stale live data + liveNow?: bool + + // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". + weekStart?: string + + // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". + refresh?: string + + // Version of the JSON schema, incremented each time a Grafana update brings + // changes to said schema. + schemaVersion: uint16 | *41 + + // Version of the dashboard, incremented each time the dashboard is updated. + version?: uint32 + + // List of dashboard panels + panels?: [...(#Panel | #RowPanel)] + + // Configured template variables + templating?: { + // List of configured template variables with their saved values along with some other metadata + list?: [...#VariableModel] + } + + // Contains the list of annotations that are associated with the dashboard. + // Annotations are used to overlay event markers and overlay event tags on graphs. + // Grafana comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the HTTP API. + // See https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/ + annotations?: #AnnotationContainer + + // Links with references to other dashboards or external websites. + links?: [...#DashboardLink] + + // Snapshot options. They are present only if the dashboard is a snapshot. + snapshot?: #Snapshot @grafanamaturity(NeedsExpertReview) + + // When set to true, the dashboard will load all panels in the dashboard when it's loaded. + preload?: bool + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + /////////////////////////////////////// + // Definitions (referenced above) are declared below + + // TODO: this should be a regular DataQuery that depends on the selected dashboard + // these match the properties of the "grafana" datasouce that is default in most dashboards + #AnnotationTarget: { + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + limit: int64 + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + matchAny: bool + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + tags: [...string] + // Only required/valid for the grafana datasource... + // but code+tests is already depending on it so hard to change + type: string + ... // datasource will stick their raw DataQuery here + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + #AnnotationPanelFilter: { + // Should the specified panels be included or excluded + exclude?: bool | *false + + // Panel IDs that should be included or excluded + ids: [...uint8] + } @cuetsy(kind="interface") + + // Contains the list of annotations that are associated with the dashboard. + // Annotations are used to overlay event markers and overlay event tags on graphs. + // Grafana comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the HTTP API. + // See https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/ + #AnnotationContainer: { + // List of annotations + list?: [...#AnnotationQuery] + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // TODO docs + // FROM: AnnotationQuery in grafana-data/src/types/annotations.ts + #AnnotationQuery: { + // Name of annotation. + name: string + + // Datasource where the annotations data is + datasource: #DataSourceRef + + // When enabled the annotation query is issued with every dashboard refresh + enable: bool | *true + + // Annotation queries can be toggled on or off at the top of the dashboard. + // When hide is true, the toggle is not shown in the dashboard. + hide?: bool | *false + + // Color to use for the annotation event markers + iconColor: string + + // Filters to apply when fetching annotations + filter?: #AnnotationPanelFilter + + // TODO.. this should just be a normal query target + target?: #AnnotationTarget + + // TODO -- this should not exist here, it is based on the --grafana-- datasource + type?: string @grafanamaturity(NeedsExpertReview) + + // Set to 1 for the standard annotation query all dashboards have by default. + builtIn?: number | *0 + + // unless datasources have migrated to the target+mapping, + // they just spread their query into the base object :( + ... + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // A variable is a placeholder for a value. You can use variables in metric queries and in panel titles. + #VariableModel: { + // Type of variable + type: #VariableType + // Name of variable + name: string + // Optional display name + label?: string + // Visibility configuration for the variable + hide?: #VariableHide + // Whether the variable value should be managed by URL query params or not + skipUrlSync?: bool | *false + // Description of variable. It can be defined but `null`. + description?: string + // Query used to fetch values for a variable + query?: string | {...} + // Data source used to fetch values for a variable. It can be defined but `null`. + datasource?: #DataSourceRef + // Shows current selected variable text/value on the dashboard + current?: #VariableOption + // Whether multiple values can be selected or not from variable value list + multi?: bool | *false + // Allow custom values to be entered in the variable + allowCustomValue?: bool | *true + // Options that can be selected for a variable. + options?: [...#VariableOption] + // Options to config when to refresh a variable + refresh?: #VariableRefresh + // Options sort order + sort?: #VariableSort + // Whether all value option is available or not + includeAll?: bool | *false + // Custom all value + allValue?: string + // Optional field, if you want to extract part of a series name or metric node segment. + // Named capture groups can be used to separate the display text and value. + regex?: string + ... + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // Option to be selected in a variable. + #VariableOption: { + // Whether the option is selected or not + selected?: bool + // Text to be displayed for the option + text: string | [...string] + // Value of the option + value: string | [...string] + } @cuetsy(kind="interface") + + // Options to config when to refresh a variable + // `0`: Never refresh the variable + // `1`: Queries the data source every time the dashboard loads. + // `2`: Queries the data source when the dashboard time range changes. + #VariableRefresh: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="never|onDashboardLoad|onTimeRangeChanged") + + // Determine if the variable shows on dashboard + // Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing). + #VariableHide: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable") @grafana(TSVeneer="type") + + // Sort variable options + // Accepted values are: + // `0`: No sorting + // `1`: Alphabetical ASC + // `2`: Alphabetical DESC + // `3`: Numerical ASC + // `4`: Numerical DESC + // `5`: Alphabetical Case Insensitive ASC + // `6`: Alphabetical Case Insensitive DESC + // `7`: Natural ASC + // `8`: Natural DESC + #VariableSort: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 @cuetsy(kind="enum",memberNames="disabled|alphabeticalAsc|alphabeticalDesc|numericalAsc|numericalDesc|alphabeticalCaseInsensitiveAsc|alphabeticalCaseInsensitiveDesc|naturalAsc|naturalDesc") + + // Ref to a DataSource instance + #DataSourceRef: { + // The plugin type-id + type?: string + + // Specific datasource instance + uid?: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // Links with references to other dashboards or external resources + #DashboardLink: { + // Title to display with the link + title: string + // Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) + type: #DashboardLinkType + // Icon name to be displayed with the link + icon: string + // Tooltip to display when the user hovers their mouse over it + tooltip: string + // Link URL. Only required/valid if the type is link + url?: string + // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards + tags: [...string] + // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards + asDropdown: bool | *false + // If true, the link will be opened in a new tab + targetBlank: bool | *false + // If true, includes current template variables values in the link as query params + includeVars: bool | *false + // If true, includes current time range in the link as query params + keepTime: bool | *false + } @cuetsy(kind="interface") + + // Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) + #DashboardLinkType: "link" | "dashboards" @cuetsy(kind="type") + + // Dashboard variable type + // `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. + // `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only). + // `constant`: Define a hidden constant. + // `datasource`: Quickly change the data source for an entire dashboard. + // `interval`: Interval variables represent time spans. + // `textbox`: Display a free text input field with an optional default value. + // `custom`: Define the variable options manually using a comma-separated list. + // `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables + #VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | + "system" | "snapshot" @cuetsy(kind="type") @grafanamaturity(NeedsExpertReview) + + // Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value. + // Continuous color interpolates a color using the percentage of a value relative to min and max. + // Accepted values are: + // `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold + // `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations + // `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations + // `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode + // `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode + // `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode + // `continuous-YlRd`: Continuous Yellow-Red palette mode + // `continuous-BlPu`: Continuous Blue-Purple palette mode + // `continuous-YlBl`: Continuous Yellow-Blue palette mode + // `continuous-blues`: Continuous Blue palette mode + // `continuous-reds`: Continuous Red palette mode + // `continuous-greens`: Continuous Green palette mode + // `continuous-purples`: Continuous Purple palette mode + // `shades`: Shades of a single color. Specify a single color, useful in an override rule. + // `fixed`: Fixed color mode. Specify a single color, useful in an override rule. + #FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades" @cuetsy(kind="enum",memberNames="Thresholds|PaletteClassic|PaletteClassicByName|ContinuousGrYlRd|ContinuousRdYlGr|ContinuousBlYlRd|ContinuousYlRd|ContinuousBlPu|ContinuousYlBl|ContinuousBlues|ContinuousReds|ContinuousGreens|ContinuousPurples|Fixed|Shades") @grafanamaturity(NeedsExpertReview) + + // Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value. + #FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type") + + // Map a field to a color. + #FieldColor: { + // The main color scheme mode. + mode: #FieldColorModeId + // The fixed color value for fixed or shades color modes. + fixedColor?: string + // Some visualizations need to know how to assign a series color from by value color schemes. + seriesBy?: #FieldColorSeriesByMode + } @cuetsy(kind="interface") + + // Position and dimensions of a panel in the grid + #GridPos: { + // Panel height. The height is the number of rows from the top edge of the panel. + h: uint32 & >0 | *9 + // Panel width. The width is the number of columns from the left edge of the panel. + w: uint32 & >0 & <=24 | *12 + // Panel x. The x coordinate is the number of columns from the left edge of the grid + x: uint32 & >=0 & <24 | *0 + // Panel y. The y coordinate is the number of rows from the top edge of the grid + y: uint32 & >=0 | *0 + // Whether the panel is fixed within the grid. If true, the panel will not be affected by other panels' interactions + static?: bool + } @cuetsy(kind="interface") + + // User-defined value for a metric that triggers visual changes in a panel when this value is met or exceeded + // They are used to conditionally style and color visualizations based on query results , and can be applied to most visualizations. + #Threshold: { + // Value represents a specified metric for the threshold, which triggers a visual change in the dashboard when this value is met or exceeded. + // Nulls currently appear here when serializing -Infinity to JSON. + value: number | null @grafanamaturity(NeedsExpertReview) + // Color represents the color of the visual change that will occur in the dashboard when the threshold value is met or exceeded. + color: string @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Thresholds can either be `absolute` (specific number) or `percentage` (relative to min or max, it will be values between 0 and 1). + #ThresholdsMode: "absolute" | "percentage" @cuetsy(kind="enum",memberNames="Absolute|Percentage") + + // Thresholds configuration for the panel + #ThresholdsConfig: { + // Thresholds mode. + mode: #ThresholdsMode + + // Must be sorted by 'value', first value is always -Infinity + steps: [...#Threshold] @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Allow to transform the visual representation of specific data values in a visualization, irrespective of their original units + #ValueMapping: #ValueMap | #RangeMap | #RegexMap | #SpecialValueMap @cuetsy(kind="type") @grafanamaturity(NeedsExpertReview) + + // Supported value mapping types + // `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. + // `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. + // `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. + // `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A. + #MappingType: "value" | "range" | "regex" | "special" @cuetsy(kind="enum",memberNames="ValueToText|RangeToText|RegexToText|SpecialValue") @grafanamaturity(NeedsExpertReview) + + // Maps text values to a color or different display text and color. + // For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. + #ValueMap: { + type: #MappingType & "value" + // Map with : ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } } + options: [string]: #ValueMappingResult + } @cuetsy(kind="interface") + + // Maps numerical ranges to a display text and color. + // For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. + #RangeMap: { + type: #MappingType & "range" + // Range to match against and the result to apply when the value is within the range + options: { + // Min value of the range. It can be null which means -Infinity + from: float64 | null + // Max value of the range. It can be null which means +Infinity + to: float64 | null + // Config to apply when the value is within the range + result: #ValueMappingResult + } + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Maps regular expressions to replacement text and a color. + // For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. + #RegexMap: { + type: #MappingType & "regex" + // Regular expression to match against and the result to apply when the value matches the regex + options: { + // Regular expression to match against + pattern: string + // Config to apply when the value matches the regex + result: #ValueMappingResult + } + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. + // See SpecialValueMatch to see the list of special values. + // For example, you can configure a special value mapping so that null values appear as N/A. + #SpecialValueMap: { + type: #MappingType & "special" + options: { + // Special value to match against + match: #SpecialValueMatch + // Config to apply when the value matches the special value + result: #ValueMappingResult + } + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) + + // Special value types supported by the `SpecialValueMap` + #SpecialValueMatch: "true" | "false" | "null" | "nan" | "null+nan" | "empty" @cuetsy(kind="enum",memberNames="True|False|Null|NaN|NullAndNan|Empty") + + // Result used as replacement with text and color when the value matches + #ValueMappingResult: { + // Text to display when the value matches + text?: string + // Text to use when the value matches + color?: string + // Icon to display when the value matches. Only specific visualizations. + icon?: string + // Position in the mapping array. Only used internally. + index?: int32 + } @cuetsy(kind="interface") + + // Transformations allow to manipulate data returned by a query before the system applies a visualization. + // Using transformations you can: rename fields, join time series data, perform mathematical operations across queries, + // use the output of one transformation as the input to another transformation, etc. + #DataTransformerConfig: { + // Unique identifier of transformer + id: string + // Disabled transformations are skipped + disabled?: bool + // Optional frame matcher. When missing it will be applied to all results + filter?: #MatcherConfig + // Where to pull DataFrames from as input to transformation + topic?: "series" | "annotations" | "alertStates" // replaced with common.DataTopic + // Options to be passed to the transformer + // Valid options depend on the transformer id + options: _ + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // Counterpart for TypeScript's TimeOption type. + #TimeOption: { + display: string + from: string + to: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // Time picker configuration + // It defines the default config for the time picker and the refresh picker for the specific dashboard. + #TimePickerConfig: { + // Whether timepicker is visible or not. + hidden?: bool | *false + // Interval options available in the refresh picker dropdown. + refresh_intervals?: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + // Quick ranges for time picker. + quick_ranges?: [...#TimeOption] + // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. + nowDelay?: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + // 0 for no shared crosshair or tooltip (default). + // 1 for shared crosshair. + // 2 for shared crosshair AND shared tooltip. + #DashboardCursorSync: *0 | 1 | 2 @cuetsy(kind="enum",memberNames="Off|Crosshair|Tooltip") + + // Schema for panel targets is specified by datasource + // plugins. We use a placeholder definition, which the Go + // schema loader either left open/as-is with the Base + // variant of the Dashboard and Panel families, or filled + // with types derived from plugins in the Instance variant. + // When working directly from CUE, importers can extend this + // type directly to achieve the same effect. + #Target: {...} + + // A dashboard snapshot shares an interactive dashboard publicly. + // It is a read-only version of a dashboard, and is not editable. + // It is possible to create a snapshot of a snapshot. + // Grafana strips away all sensitive information from the dashboard. + // Sensitive information stripped: queries (metric, template,annotation) and panel links. + #Snapshot: { + // Time when the snapshot was created + created: string & t.Time + // Time when the snapshot expires, default is never to expire + expires: string @grafanamaturity(NeedsExpertReview) + // Is the snapshot saved in an external grafana instance + external: bool @grafanamaturity(NeedsExpertReview) + // external url, if snapshot was shared in external grafana instance + externalUrl: string @grafanamaturity(NeedsExpertReview) + // original url, url of the dashboard that was snapshotted + originalUrl: string @grafanamaturity(NeedsExpertReview) + // Unique identifier of the snapshot + id: uint32 @grafanamaturity(NeedsExpertReview) + // Optional, defined the unique key of the snapshot, required if external is true + key: string @grafanamaturity(NeedsExpertReview) + // Optional, name of the snapshot + name: string @grafanamaturity(NeedsExpertReview) + // org id of the snapshot + orgId: uint32 @grafanamaturity(NeedsExpertReview) + // last time when the snapshot was updated + updated: string & t.Time + // url of the snapshot, if snapshot was shared internally + url?: string @grafanamaturity(NeedsExpertReview) + // user id of the snapshot creator + userId: uint32 @grafanamaturity(NeedsExpertReview) + } @grafanamaturity(NeedsExpertReview) + + // Dashboard panels are the basic visualization building blocks. + #Panel: { + // The panel plugin type id. This is used to find the plugin to display the panel. + type: string & strings.MinRunes(1) + + // Unique identifier of the panel. Generated by Grafana when creating a new panel. It must be unique within a dashboard, but not globally. + id?: uint32 + + // The version of the plugin that is used for this panel. This is used to find the plugin to display the panel and to migrate old panel configs. + pluginVersion?: string + + // Depends on the panel plugin. See the plugin documentation for details. + targets?: [...#Target] + + // Panel title. + title?: string + + // Panel description. + description?: string + + // Whether to display the panel without a background. + transparent?: bool | *false + + // The datasource used in all targets. + datasource?: #DataSourceRef + + // Grid position. + gridPos?: #GridPos + + // Panel links. + links?: [...#DashboardLink] + + // Name of template variable to repeat for. + repeat?: string + + // Direction to repeat in if 'repeat' is set. + // `h` for horizontal, `v` for vertical. + repeatDirection?: *"h" | "v" + + // Option for repeated panels that controls max items per row + // Only relevant for horizontally repeated panels + maxPerRow?: number + + // The maximum number of data points that the panel queries are retrieving. + maxDataPoints?: number + + // List of transformations that are applied to the panel data before rendering. + // When there are multiple transformations, Grafana applies them in the order they are listed. + // Each transformation creates a result set that then passes on to the next transformation in the processing pipeline. + transformations?: [...#DataTransformerConfig] + + // The min time interval setting defines a lower limit for the $__interval and $__interval_ms variables. + // This value must be formatted as a number followed by a valid time + // identifier like: "40s", "3d", etc. + // See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options + interval?: string + + // Overrides the relative time range for individual panels, + // which causes them to be different than what is selected in + // the dashboard time picker in the top-right corner of the dashboard. You can use this to show metrics from different + // time periods or days on the same dashboard. + // The value is formatted as time operation like: `now-5m` (Last 5 minutes), `now/d` (the day so far), + // `now-5d/d`(Last 5 days), `now/w` (This week so far), `now-2y/y` (Last 2 years). + // Note: Panel time overrides have no effect when the dashboard’s time range is absolute. + // See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options + timeFrom?: string + + // Overrides the time range for individual panels by shifting its start and end relative to the time picker. + // For example, you can shift the time range for the panel to be two hours earlier than the dashboard time picker setting `2h`. + // Note: Panel time overrides have no effect when the dashboard’s time range is absolute. + // See: https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/#query-options + timeShift?: string + + // Controls if the timeFrom or timeShift overrides are shown in the panel header + hideTimeOverride?: bool + + // Dynamically load the panel + libraryPanel?: #LibraryPanelRef + + // Sets panel queries cache timeout. + cacheTimeout?: string + + // Overrides the data source configured time-to-live for a query cache item in milliseconds + queryCachingTTL?: number + + // It depends on the panel plugin. They are specified by the Options field in panel plugin schemas. + options?: {...} @grafanamaturity(NeedsExpertReview) + + // Field options allow you to change how the data is displayed in your visualizations. + fieldConfig?: #FieldConfigSource + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. + // Each column within this structure is called a field. A field can represent a single time series or table column. + // Field options allow you to change how the data is displayed in your visualizations. + #FieldConfigSource: { + // Defaults are the options applied to all fields. + defaults: #FieldConfig + // Overrides are the options applied to specific fields overriding the defaults. + overrides: [...{ + matcher: #MatcherConfig + properties: [...#DynamicConfigValue] + }] @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // A library panel is a reusable panel that you can use in any dashboard. + // When you make a change to a library panel, that change propagates to all instances of where the panel is used. + // Library panels streamline reuse of panels across multiple dashboards. + #LibraryPanelRef: { + // Library panel name + name: string + // Library panel uid + uid: string + } @cuetsy(kind="interface") + + // Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. + // It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. + #MatcherConfig: { + // The matcher id. This is used to find the matcher implementation from registry. + id: string | *"" @grafanamaturity(NeedsExpertReview) + // The matcher options. This is specific to the matcher implementation. + options?: _ @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + + #DynamicConfigValue: { + id: string | *"" @grafanamaturity(NeedsExpertReview) + value?: _ @grafanamaturity(NeedsExpertReview) + } + + // The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. + // Each column within this structure is called a field. A field can represent a single time series or table column. + // Field options allow you to change how the data is displayed in your visualizations. + #FieldConfig: { + // The display value for this field. This supports template variables blank is auto + displayName?: string @grafanamaturity(NeedsExpertReview) + + // This can be used by data sources that return and explicit naming structure for values and labels + // When this property is configured, this value is used rather than the default naming strategy. + displayNameFromDS?: string @grafanamaturity(NeedsExpertReview) + + // Human readable field metadata + description?: string @grafanamaturity(NeedsExpertReview) + + // An explicit path to the field in the datasource. When the frame meta includes a path, + // This will default to `${frame.meta.path}/${field.name} + // + // When defined, this value can be used as an identifier within the datasource scope, and + // may be used to update the results + path?: string @grafanamaturity(NeedsExpertReview) + + // True if data source can write a value to the path. Auth/authz are supported separately + writeable?: bool @grafanamaturity(NeedsExpertReview) + + // True if data source field supports ad-hoc filters + filterable?: bool @grafanamaturity(NeedsExpertReview) + + // Unit a field should use. The unit you select is applied to all fields except time. + // You can use the units ID availables in Grafana or a custom unit. + // Available units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts + // As custom unit, you can use the following formats: + // `suffix:` for custom unit that should go after value. + // `prefix:` for custom unit that should go before value. + // `time:` For custom date time formats type for example `time:YYYY-MM-DD`. + // `si:` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character. + // `count:` for a custom count unit. + // `currency:` for custom a currency unit. + unit?: string @grafanamaturity(NeedsExpertReview) + + // Specify the number of decimals Grafana includes in the rendered value. + // If you leave this field blank, Grafana automatically truncates the number of decimals based on the value. + // For example 1.1234 will display as 1.12 and 100.456 will display as 100. + // To display all decimals, set the unit to `String`. + decimals?: number @grafanamaturity(NeedsExpertReview) + + // The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + min?: number @grafanamaturity(NeedsExpertReview) + // The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + max?: number @grafanamaturity(NeedsExpertReview) + + // Convert input values into a display string + mappings?: [...#ValueMapping] @grafanamaturity(NeedsExpertReview) + + // Map numeric values to states + thresholds?: #ThresholdsConfig @grafanamaturity(NeedsExpertReview) + + // Panel color configuration + color?: #FieldColor + + // The behavior when clicking on a result + links?: [...] @grafanamaturity(NeedsExpertReview) + + // Alternative to empty string + noValue?: string @grafanamaturity(NeedsExpertReview) + + // custom is specified by the FieldConfig field + // in panel plugin schemas. + custom?: {...} @grafanamaturity(NeedsExpertReview) + } @cuetsy(kind="interface") @grafana(TSVeneer="type") @grafanamaturity(NeedsExpertReview) + + // Row panel + #RowPanel: { + // The panel type + type: "row" + + // Whether this row should be collapsed or not. + collapsed: bool | *false + + // Row title + title?: string + + // Name of default datasource for the row + datasource?: #DataSourceRef + + // Row grid position + gridPos?: #GridPos + + // Unique identifier of the panel. Generated by Grafana when creating a new panel. It must be unique within a dashboard, but not globally. + id: uint32 + + // List of panels in the row + panels: [...#Panel] + + // Name of template variable to repeat for. + repeat?: string + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + } +}, +] diff --git a/apps/dashboard/pkg/apis/dashboard/v1alpha1/validation.go b/apps/dashboard/pkg/apis/dashboard/v1alpha1/validation.go new file mode 100644 index 00000000000..37117b10108 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v1alpha1/validation.go @@ -0,0 +1,90 @@ +package v1alpha1 + +import ( + _ "embed" + json "encoding/json" + fmt "fmt" + "strings" + "sync" + + "k8s.io/apimachinery/pkg/util/validation/field" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/errors" + cuejson "cuelang.org/go/encoding/json" + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" +) + +func ValidateDashboardSpec(obj *Dashboard, forceValidation bool) (field.ErrorList, field.ErrorList) { + var schemaVersionError field.ErrorList + schemaVersion := schemaversion.GetSchemaVersion(obj.Spec.Object) + if schemaVersion != schemaversion.LATEST_VERSION { + schemaVersionError = field.ErrorList{field.Invalid(field.NewPath("spec", "schemaVersion"), field.OmitValueType{}, fmt.Sprintf("Schema version %d is not supported - please upgrade to %d", schemaVersion, schemaversion.LATEST_VERSION))} + if !forceValidation { + return nil, schemaVersionError + } + } + + data, err := json.Marshal(obj.Spec.Object) + if err != nil { + return field.ErrorList{ + field.Invalid(field.NewPath("spec"), field.OmitValueType{}, err.Error()), + }, schemaVersionError + } + + if err := cuejson.Validate(data, getCueSchema()); err != nil { + errs := field.ErrorList{} + + for _, e := range errors.Errors(err) { + if + // We don't want to return confusing "empty disjunction" errors, + // because the users don't necessarily understand what to do with them. + // For empty disjunctions, CUE will also return more specific errors, + // so we can safely ignore the generic ones. + strings.Contains(e.Error(), "disjunction") || + // We don't want to return errors about unknown fields either. + strings.Contains(e.Error(), "field not allowed") { + continue + } + + // We want to manually format the error message, + // because e.Error() contains the full CUE path. + format, args := e.Msg() + + errs = append(errs, field.Invalid( + field.NewPath(formatErrorPath(e.Path())), + field.OmitValueType{}, + fmt.Sprintf(format, args...), + )) + } + + return errs, schemaVersionError + } + + return nil, schemaVersionError +} + +func formatErrorPath(path []string) string { + // omitting the "lineage.schemas[0].schema.spec" prefix here. + return strings.Join(path[4:], ".") +} + +var ( + compiledSchema cue.Value + getSchemaOnce sync.Once +) + +//go:embed dashboard_kind.cue +var schemaSource string + +func getCueSchema() cue.Value { + getSchemaOnce.Do(func() { + cueCtx := cuecontext.New() + compiledSchema = cueCtx.CompileString(schemaSource).LookupPath( + cue.ParsePath("lineage.schemas[0].schema.spec"), + ) + }) + + return compiledSchema +} diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue new file mode 100644 index 00000000000..4e6ace03e5f --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue @@ -0,0 +1,965 @@ +// This file is managed by grafana-app-sdk - DO NOT EDIT MANUALLY +// Source: apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +// To sync changes, run: make generate in apps/dashboard + +package v2alpha1 + +DashboardSpec: { + // Title of dashboard. + annotations: [...AnnotationQueryKind] + + // Configuration of dashboard cursor sync behavior. + // "Off" for no shared crosshair or tooltip (default). + // "Crosshair" for shared crosshair. + // "Tooltip" for shared crosshair AND shared tooltip. + cursorSync: DashboardCursorSync + + // Description of dashboard. + description?: string + + // Whether a dashboard is editable or not. + editable?: bool | *true + + elements: [ElementReference.name]: Element + + layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind + + // Links with references to other dashboards or external websites. + links: [...DashboardLink] + + // When set to true, the dashboard will redraw panels at an interval matching the pixel width. + // This will keep data "moving left" regardless of the query refresh rate. This setting helps + // avoid dashboards presenting stale live data. + liveNow?: bool + + // When set to true, the dashboard will load all panels in the dashboard when it's loaded. + preload: bool + + // Plugins only. The version of the dashboard installed together with the plugin. + // This is used to determine if the dashboard should be updated when the plugin is updated. + revision?: uint16 + + // Tags associated with dashboard. + tags: [...string] + + timeSettings: TimeSettingsSpec + + // Title of dashboard. + title: string + + // Configured template variables. + variables: [...VariableKind] +} + +// Supported dashboard elements +Element: PanelKind | LibraryPanelKind // |* more element types in the future + +LibraryPanelKind: { + kind: "LibraryPanel" + spec: LibraryPanelKindSpec +} + +LibraryPanelKindSpec: { + // Panel ID for the library panel in the dashboard + id: number + // Title for the library panel in the dashboard + title: string + + libraryPanel: LibraryPanelRef +} + +// A library panel is a reusable panel that you can use in any dashboard. +// When you make a change to a library panel, that change propagates to all instances of where the panel is used. +// Library panels streamline reuse of panels across multiple dashboards. +LibraryPanelRef: { + // Library panel name + name: string + // Library panel uid + uid: string +} + +AnnotationPanelFilter: { + // Should the specified panels be included or excluded + exclude?: bool | *false + + // Panel IDs that should be included or excluded + ids: [...uint8] +} + +// "Off" for no shared crosshair or tooltip (default). +// "Crosshair" for shared crosshair. +// "Tooltip" for shared crosshair AND shared tooltip. +DashboardCursorSync: "Off" | "Crosshair" | "Tooltip" + +// Links with references to other dashboards or external resources +DashboardLink: { + // Title to display with the link + title: string + // Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) + // FIXME: The type is generated as `type: DashboardLinkType | dashboardLinkType.Link;` but it should be `type: DashboardLinkType` + type: DashboardLinkType + // Icon name to be displayed with the link + icon: string + // Tooltip to display when the user hovers their mouse over it + tooltip: string + // Link URL. Only required/valid if the type is link + url?: string + // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards + tags: [...string] + // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards + asDropdown: bool | *false + // If true, the link will be opened in a new tab + targetBlank: bool | *false + // If true, includes current template variables values in the link as query params + includeVars: bool | *false + // If true, includes current time range in the link as query params + keepTime: bool | *false +} + +DataSourceRef: { + // The plugin type-id + type?: string + + // Specific datasource instance + uid?: string +} + +// A topic is attached to DataFrame metadata in query results. +// This specifies where the data should be used. +DataTopic: "series" | "annotations" | "alertStates" @cog(kind="enum",memberNames="Series|Annotations|AlertStates") + +// Transformations allow to manipulate data returned by a query before the system applies a visualization. +// Using transformations you can: rename fields, join time series data, perform mathematical operations across queries, +// use the output of one transformation as the input to another transformation, etc. +DataTransformerConfig: { + // Unique identifier of transformer + id: string + // Disabled transformations are skipped + disabled?: bool + // Optional frame matcher. When missing it will be applied to all results + filter?: MatcherConfig + // Where to pull DataFrames from as input to transformation + topic?: DataTopic + // Options to be passed to the transformer + // Valid options depend on the transformer id + options: _ +} + +DataLink: { + title: string + url: string + targetBlank?: bool +} + +// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. +// Each column within this structure is called a field. A field can represent a single time series or table column. +// Field options allow you to change how the data is displayed in your visualizations. +FieldConfigSource: { + // Defaults are the options applied to all fields. + defaults: FieldConfig + // Overrides are the options applied to specific fields overriding the defaults. + overrides: [...{ + matcher: MatcherConfig + properties: [...DynamicConfigValue] + }] +} + +// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. +// Each column within this structure is called a field. A field can represent a single time series or table column. +// Field options allow you to change how the data is displayed in your visualizations. +FieldConfig: { + // The display value for this field. This supports template variables blank is auto + displayName?: string + + // This can be used by data sources that return and explicit naming structure for values and labels + // When this property is configured, this value is used rather than the default naming strategy. + displayNameFromDS?: string + + // Human readable field metadata + description?: string + + // An explicit path to the field in the datasource. When the frame meta includes a path, + // This will default to `${frame.meta.path}/${field.name} + // + // When defined, this value can be used as an identifier within the datasource scope, and + // may be used to update the results + path?: string + + // True if data source can write a value to the path. Auth/authz are supported separately + writeable?: bool + + // True if data source field supports ad-hoc filters + filterable?: bool + + // Unit a field should use. The unit you select is applied to all fields except time. + // You can use the units ID availables in Grafana or a custom unit. + // Available units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts + // As custom unit, you can use the following formats: + // `suffix:` for custom unit that should go after value. + // `prefix:` for custom unit that should go before value. + // `time:` For custom date time formats type for example `time:YYYY-MM-DD`. + // `si:` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character. + // `count:` for a custom count unit. + // `currency:` for custom a currency unit. + unit?: string + + // Specify the number of decimals Grafana includes in the rendered value. + // If you leave this field blank, Grafana automatically truncates the number of decimals based on the value. + // For example 1.1234 will display as 1.12 and 100.456 will display as 100. + // To display all decimals, set the unit to `String`. + decimals?: number + + // The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + min?: number + // The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + max?: number + + // Convert input values into a display string + mappings?: [...ValueMapping] + + // Map numeric values to states + thresholds?: ThresholdsConfig + + // Panel color configuration + color?: FieldColor + + // The behavior when clicking on a result + links?: [...] + + // Alternative to empty string + noValue?: string + + // custom is specified by the FieldConfig field + // in panel plugin schemas. + custom?: {...} +} + +DynamicConfigValue: { + id: string | *"" + value?: _ +} + +// Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. +// It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. +MatcherConfig: { + // The matcher id. This is used to find the matcher implementation from registry. + id: string | *"" + // The matcher options. This is specific to the matcher implementation. + options?: _ +} + +Threshold: { + value: number + color: string +} + +ThresholdsMode: "absolute" | "percentage" + +ThresholdsConfig: { + mode: ThresholdsMode + steps: [...Threshold] +} + +ValueMapping: ValueMap | RangeMap | RegexMap | SpecialValueMap + +// Supported value mapping types +// `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. +// `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. +// `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. +// `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A. +MappingType: "value" | "range" | "regex" | "special" + +// Maps text values to a color or different display text and color. +// For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. +ValueMap: { + type: MappingType & "value" + // Map with : ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } } + options: [string]: ValueMappingResult +} + +// Maps numerical ranges to a display text and color. +// For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. +RangeMap: { + type: MappingType & "range" + // Range to match against and the result to apply when the value is within the range + options: { + // Min value of the range. It can be null which means -Infinity + from: float64 | null + // Max value of the range. It can be null which means +Infinity + to: float64 | null + // Config to apply when the value is within the range + result: ValueMappingResult + } +} + +// Maps regular expressions to replacement text and a color. +// For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. +RegexMap: { + type: MappingType & "regex" + // Regular expression to match against and the result to apply when the value matches the regex + options: { + // Regular expression to match against + pattern: string + // Config to apply when the value matches the regex + result: ValueMappingResult + } +} + +// Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. +// See SpecialValueMatch to see the list of special values. +// For example, you can configure a special value mapping so that null values appear as N/A. +SpecialValueMap: { + type: MappingType & "special" + options: { + // Special value to match against + match: SpecialValueMatch + // Config to apply when the value matches the special value + result: ValueMappingResult + } +} + +// Special value types supported by the `SpecialValueMap` +SpecialValueMatch: "true" | "false" | "null" | "nan" | "null+nan" | "empty" @cog(kind="enum",memberNames="True|False|Null|NaN|NullAndNaN|Empty") + +// Result used as replacement with text and color when the value matches +ValueMappingResult: { + // Text to display when the value matches + text?: string + // Text to use when the value matches + color?: string + // Icon to display when the value matches. Only specific visualizations. + icon?: string + // Position in the mapping array. Only used internally. + index?: int32 +} + +// Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value. +// Continuous color interpolates a color using the percentage of a value relative to min and max. +// Accepted values are: +// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold +// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations +// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations +// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode +// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode +// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode +// `continuous-YlRd`: Continuous Yellow-Red palette mode +// `continuous-BlPu`: Continuous Blue-Purple palette mode +// `continuous-YlBl`: Continuous Yellow-Blue palette mode +// `continuous-blues`: Continuous Blue palette mode +// `continuous-reds`: Continuous Red palette mode +// `continuous-greens`: Continuous Green palette mode +// `continuous-purples`: Continuous Purple palette mode +// `shades`: Shades of a single color. Specify a single color, useful in an override rule. +// `fixed`: Fixed color mode. Specify a single color, useful in an override rule. +FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades" + +// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value. +FieldColorSeriesByMode: "min" | "max" | "last" + +// Map a field to a color. +FieldColor: { + // The main color scheme mode. + mode: FieldColorModeId + // The fixed color value for fixed or shades color modes. + fixedColor?: string + // Some visualizations need to know how to assign a series color from by value color schemes. + seriesBy?: FieldColorSeriesByMode +} + +// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) +DashboardLinkType: "link" | "dashboards" + +// --- Common types --- +Kind: { + kind: string + spec: _ + metadata?: _ +} + +// --- Kinds --- +VizConfigSpec: { + pluginVersion: string + options: [string]: _ + fieldConfig: FieldConfigSource +} + +VizConfigKind: { + // The kind of a VizConfigKind is the plugin ID + kind: string + spec: VizConfigSpec +} + +AnnotationQuerySpec: { + datasource?: DataSourceRef + query?: DataQueryKind + enable: bool + hide: bool + iconColor: string + name: string + builtIn?: bool | *false + filter?: AnnotationPanelFilter + options?: [string]: _ //Catch-all field for datasource-specific properties +} + +AnnotationQueryKind: { + kind: "AnnotationQuery" + spec: AnnotationQuerySpec +} + +QueryOptionsSpec: { + timeFrom?: string + maxDataPoints?: int + timeShift?: string + queryCachingTTL?: int + interval?: string + cacheTimeout?: string + hideTimeOverride?: bool +} + +DataQueryKind: { + // The kind of a DataQueryKind is the datasource type + kind: string + spec: [string]: _ +} + +PanelQuerySpec: { + query: DataQueryKind + datasource?: DataSourceRef + + refId: string + hidden: bool +} + +PanelQueryKind: { + kind: "PanelQuery" + spec: PanelQuerySpec +} + +TransformationKind: { + // The kind of a TransformationKind is the transformation ID + kind: string + spec: DataTransformerConfig +} + +QueryGroupSpec: { + queries: [...PanelQueryKind] + transformations: [...TransformationKind] + queryOptions: QueryOptionsSpec +} + +QueryGroupKind: { + kind: "QueryGroup" + spec: QueryGroupSpec +} + +TimeRangeOption: { + display: string | *"Last 6 hours" + from: string | *"now-6h" + to: string | *"now" +} + +// Time configuration +// It defines the default time config for the time picker, the refresh picker for the specific dashboard. +TimeSettingsSpec: { + // Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". + timezone?: string | *"browser" + // Start time range for dashboard. + // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". + from: string | *"now-6h" + // End time range for dashboard. + // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". + to: string | *"now" + // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". + autoRefresh: string // v1: refresh + // Interval options available in the refresh picker dropdown. + autoRefreshIntervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] // v1: timepicker.refresh_intervals + // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. + quickRanges?: [...TimeRangeOption] // v1: timepicker.quick_ranges , not exposed in the UI + // Whether timepicker is visible or not. + hideTimepicker: bool // v1: timepicker.hidden + // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". + weekStart?: "saturday" | "monday" | "sunday" + // The month that the fiscal year starts on. 0 = January, 11 = December + fiscalYearStartMonth: int + // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. + nowDelay?: string // v1: timepicker.nowDelay +} + +RepeatMode: "variable" // other repeat modes will be added in the future: label, frame + +RepeatOptions: { + mode: RepeatMode + value: string + direction?: "h" | "v" + maxPerRow?: int +} + +RowRepeatOptions: { + mode: RepeatMode + value: string +} + +AutoGridRepeatOptions: { + mode: RepeatMode + value: string +} + +GridLayoutItemSpec: { + x: int + y: int + width: int + height: int + element: ElementReference // reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference + repeat?: RepeatOptions +} + +GridLayoutItemKind: { + kind: "GridLayoutItem" + spec: GridLayoutItemSpec +} + +GridLayoutRowKind: { + kind: "GridLayoutRow" + spec: GridLayoutRowSpec +} + +GridLayoutRowSpec: { + y: int + collapsed: bool + title: string + elements: [...GridLayoutItemKind] // Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard. + repeat?: RowRepeatOptions +} + +GridLayoutSpec: { + items: [...GridLayoutItemKind | GridLayoutRowKind] +} + +GridLayoutKind: { + kind: "GridLayout" + spec: GridLayoutSpec +} + +RowsLayoutKind: { + kind: "RowsLayout" + spec: RowsLayoutSpec +} + +RowsLayoutSpec: { + rows: [...RowsLayoutRowKind] +} + +RowsLayoutRowKind: { + kind: "RowsLayoutRow" + spec: RowsLayoutRowSpec +} + +RowsLayoutRowSpec: { + title?: string + collapse?: bool + hideHeader?: bool + fillScreen?: bool + conditionalRendering?: ConditionalRenderingGroupKind + repeat?: RowRepeatOptions + layout: GridLayoutKind | AutoGridLayoutKind | TabsLayoutKind | RowsLayoutKind +} + +AutoGridLayoutKind: { + kind: "AutoGridLayout" + spec: AutoGridLayoutSpec +} + +AutoGridLayoutSpec: { + maxColumnCount?: number | *3 + columnWidthMode: "narrow" | *"standard" | "wide" | "custom" + columnWidth?: number + rowHeightMode: "short" | *"standard" | "tall" | "custom" + rowHeight?: number + fillScreen?: bool | *false + items: [...AutoGridLayoutItemKind] +} + +AutoGridLayoutItemKind: { + kind: "AutoGridLayoutItem" + spec: AutoGridLayoutItemSpec +} + +AutoGridLayoutItemSpec: { + element: ElementReference + repeat?: AutoGridRepeatOptions + conditionalRendering?: ConditionalRenderingGroupKind +} + +TabsLayoutKind: { + kind: "TabsLayout" + spec: TabsLayoutSpec +} + +TabsLayoutSpec: { + tabs: [...TabsLayoutTabKind] +} + +TabsLayoutTabKind: { + kind: "TabsLayoutTab" + spec: TabsLayoutTabSpec +} + +TabsLayoutTabSpec: { + title?: string + layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind + conditionalRendering?: ConditionalRenderingGroupKind +} + +PanelSpec: { + id: number + title: string + description: string + links: [...DataLink] + data: QueryGroupKind + vizConfig: VizConfigKind + transparent?: bool +} + +PanelKind: { + kind: "Panel" + spec: PanelSpec +} + +ElementReference: { + kind: "ElementReference" + name: string +} + +// Start FIXME: variables - in CUE PR - this are things that should be added into the cue schema +// TODO: properties such as `hide`, `skipUrlSync`, `multi` are type boolean, and in the old schema they are conditional, +// should we make them conditional in the new schema as well? or should we make them required but default to false? + +// Variable types +VariableValue: VariableValueSingle | [...VariableValueSingle] + +VariableValueSingle: string | bool | number | CustomVariableValue + +// Custom formatter variable +CustomFormatterVariable: { + name: string + type: VariableType + multi: bool + includeAll: bool +} + +// Custom variable value +CustomVariableValue: { + // The format name or function used in the expression + formatter: *null | string | VariableCustomFormatterFn +} + +// Custom formatter function +VariableCustomFormatterFn: { + value: _ + legacyVariableModel: { + name: string + type: VariableType + multi: bool + includeAll: bool + } + legacyDefaultFormatter?: VariableCustomFormatterFn +} + +// Dashboard variable type +// `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. +// `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only). +// `constant`: Define a hidden constant. +// `datasource`: Quickly change the data source for an entire dashboard. +// `interval`: Interval variables represent time spans. +// `textbox`: Display a free text input field with an optional default value. +// `custom`: Define the variable options manually using a comma-separated list. +// `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables +VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | + "system" | "snapshot" + +VariableKind: QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind + +// Sort variable options +// Accepted values are: +// `disabled`: No sorting +// `alphabeticalAsc`: Alphabetical ASC +// `alphabeticalDesc`: Alphabetical DESC +// `numericalAsc`: Numerical ASC +// `numericalDesc`: Numerical DESC +// `alphabeticalCaseInsensitiveAsc`: Alphabetical Case Insensitive ASC +// `alphabeticalCaseInsensitiveDesc`: Alphabetical Case Insensitive DESC +// `naturalAsc`: Natural ASC +// `naturalDesc`: Natural DESC +// VariableSort enum with default value +VariableSort: "disabled" | "alphabeticalAsc" | "alphabeticalDesc" | "numericalAsc" | "numericalDesc" | "alphabeticalCaseInsensitiveAsc" | "alphabeticalCaseInsensitiveDesc" | "naturalAsc" | "naturalDesc" + +// Options to config when to refresh a variable +// `never`: Never refresh the variable +// `onDashboardLoad`: Queries the data source every time the dashboard loads. +// `onTimeRangeChanged`: Queries the data source when the dashboard time range changes. +VariableRefresh: *"never" | "onDashboardLoad" | "onTimeRangeChanged" + +// Determine if the variable shows on dashboard +// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing). +VariableHide: *"dontHide" | "hideLabel" | "hideVariable" + +// FIXME: should we introduce this? --- Variable value option +VariableValueOption: { + label: string + value: VariableValueSingle + group?: string +} + +// Variable option specification +VariableOption: { + // Whether the option is selected or not + selected?: bool + // Text to be displayed for the option + text: string | [...string] + // Value of the option + value: string | [...string] +} + +// Query variable specification +QueryVariableSpec: { + name: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + label?: string + hide: VariableHide + refresh: VariableRefresh + skipUrlSync: bool | *false + description?: string + datasource?: DataSourceRef + query: DataQueryKind + regex: string | *"" + sort: VariableSort + definition?: string + options: [...VariableOption] | *[] + multi: bool | *false + includeAll: bool | *false + allValue?: string + placeholder?: string +} + +// Query variable kind +QueryVariableKind: { + kind: "QueryVariable" + spec: QueryVariableSpec +} + +// Text variable specification +TextVariableSpec: { + name: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + query: string | *"" + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Text variable kind +TextVariableKind: { + kind: "TextVariable" + spec: TextVariableSpec +} + +// Constant variable specification +ConstantVariableSpec: { + name: string | *"" + query: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Constant variable kind +ConstantVariableKind: { + kind: "ConstantVariable" + spec: ConstantVariableSpec +} + +// Datasource variable specification +DatasourceVariableSpec: { + name: string | *"" + pluginId: string | *"" + refresh: VariableRefresh + regex: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + options: [...VariableOption] | *[] + multi: bool | *false + includeAll: bool | *false + allValue?: string + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Datasource variable kind +DatasourceVariableKind: { + kind: "DatasourceVariable" + spec: DatasourceVariableSpec +} + +// Interval variable specification +IntervalVariableSpec: { + name: string | *"" + query: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + options: [...VariableOption] | *[] + auto: bool | *false + auto_min: string | *"" + auto_count: int | *0 + refresh: VariableRefresh + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Interval variable kind +IntervalVariableKind: { + kind: "IntervalVariable" + spec: IntervalVariableSpec +} + +// Custom variable specification +CustomVariableSpec: { + name: string | *"" + query: string | *"" + current: VariableOption + options: [...VariableOption] | *[] + multi: bool | *false + includeAll: bool | *false + allValue?: string + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Custom variable kind +CustomVariableKind: { + kind: "CustomVariable" + spec: CustomVariableSpec +} + +// GroupBy variable specification +GroupByVariableSpec: { + name: string | *"" + datasource?: DataSourceRef + current: VariableOption | *{ + text: "" + value: "" + } + options: [...VariableOption] | *[] + multi: bool | *false + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Group variable kind +GroupByVariableKind: { + kind: "GroupByVariable" + spec: GroupByVariableSpec +} + +// Adhoc variable specification +AdhocVariableSpec: { + name: string | *"" + datasource?: DataSourceRef + baseFilters: [...AdHocFilterWithLabels] | *[] + filters: [...AdHocFilterWithLabels] | *[] + defaultKeys: [...MetricFindValue] | *[] + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Define the MetricFindValue type +MetricFindValue: { + text: string + value?: string | number + group?: string + expandable?: bool +} + +// Define the AdHocFilterWithLabels type +AdHocFilterWithLabels: { + key: string + operator: string + value: string + values?: [...string] + keyLabel?: string + valueLabels?: [...string] + forceEdit?: bool + // @deprecated + condition?: string +} + +// Adhoc variable kind +AdhocVariableKind: { + kind: "AdhocVariable" + spec: AdhocVariableSpec +} + +ConditionalRenderingGroupKind: { + kind: "ConditionalRenderingGroup" + spec: ConditionalRenderingGroupSpec +} + +ConditionalRenderingGroupSpec: { + visibility: "show" | "hide" + condition: "and" | "or" + items: [...ConditionalRenderingVariableKind | ConditionalRenderingDataKind | ConditionalRenderingTimeRangeSizeKind] +} + +ConditionalRenderingVariableKind: { + kind: "ConditionalRenderingVariable" + spec: ConditionalRenderingVariableSpec +} + +ConditionalRenderingVariableSpec: { + variable: string + operator: "equals" | "notEquals" + value: string +} + +ConditionalRenderingDataKind: { + kind: "ConditionalRenderingData" + spec: ConditionalRenderingDataSpec +} + +ConditionalRenderingDataSpec: { + value: bool +} + +ConditionalRenderingTimeRangeSizeKind: { + kind: "ConditionalRenderingTimeRangeSize" + spec: ConditionalRenderingTimeRangeSizeSpec +} + +ConditionalRenderingTimeRangeSizeSpec: { + value: string +} diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go new file mode 100644 index 00000000000..7445000d7f8 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go @@ -0,0 +1,84 @@ +package v2alpha1 + +import ( + _ "embed" + json "encoding/json" + fmt "fmt" + "strings" + "sync" + + "k8s.io/apimachinery/pkg/util/validation/field" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/errors" + cuejson "cuelang.org/go/encoding/json" +) + +func ValidateDashboardSpec(obj *Dashboard) field.ErrorList { + data, err := json.Marshal(obj.Spec) + if err != nil { + return field.ErrorList{ + field.Invalid(field.NewPath("spec"), field.OmitValueType{}, err.Error()), + } + } + + if err := cuejson.Validate(data, getCueSchema()); err != nil { + errs := field.ErrorList{} + + for _, e := range errors.Errors(err) { + if + // We don't want to return confusing "empty disjunction" errors, + // because the users don't necessarily understand what to do with them. + // For empty disjunctions, CUE will also return more specific errors, + // so we can safely ignore the generic ones. + strings.Contains(e.Error(), "disjunction") || + // We don't want to return errors about unknown fields either. + strings.Contains(e.Error(), "field not allowed") { + continue + } + + if strings.Contains(e.Error(), "mismatched types null and list") { + // Go populates empty slices as nil, which the cue validator does not like + continue + } + + // We want to manually format the error message, + // because e.Error() contains the full CUE path. + format, args := e.Msg() + + errs = append(errs, field.Invalid( + field.NewPath(formatErrorPath(e.Path())), + field.OmitValueType{}, + fmt.Sprintf(format, args...), + )) + } + + return errs + } + + return nil +} + +func formatErrorPath(path []string) string { + return strings.Join(path, ".") +} + +var ( + compiledSchema cue.Value + getSchemaOnce sync.Once +) + +//go:embed dashboard_spec.cue +var schemaSource string + +func getCueSchema() cue.Value { + getSchemaOnce.Do(func() { + cueCtx := cuecontext.New() + compiledSchema = cueCtx.CompileString(schemaSource).LookupPath( + cue.ParsePath("DashboardSpec"), + ) + }) + + return compiledSchema +} diff --git a/devenv/dev-dashboards/all-panels.json b/devenv/dev-dashboards/all-panels.json index b9c95e8663e..be812e67ffa 100644 --- a/devenv/dev-dashboards/all-panels.json +++ b/devenv/dev-dashboards/all-panels.json @@ -883,7 +883,7 @@ } ], "refresh": "", - "schemaVersion": 33, + "schemaVersion": 36, "tags": [ "gdev", "panel-tests", diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index b8262500040..00b11fcc5c2 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -157,6 +157,9 @@ Experimental features might be changed or removed without prior notice. | `disableClassicHTTPHistogram` | Disables classic HTTP Histogram (use with enableNativeHTTPHistogram) | | `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint | | `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards | +| `dashboardDisableSchemaValidationV1` | Disable schema validation for dashboards/v1 | +| `dashboardDisableSchemaValidationV2` | Disable schema validation for dashboards/v2 | +| `dashboardSchemaValidationLogging` | Log schema validation errors so they can be analyzed later | | `datasourceQueryTypes` | Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) | | `queryService` | Register /apis/query.grafana.app/ -- will eventually replace /api/ds/query | | `queryServiceRewrite` | Rewrite requests targeting /ds/query to the query service | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 89c02a1a693..71fd3773c2e 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -340,6 +340,18 @@ export interface FeatureToggles { */ kubernetesClientDashboardsFolders?: boolean; /** + * Disable schema validation for dashboards/v1 + */ + dashboardDisableSchemaValidationV1?: boolean; + /** + * Disable schema validation for dashboards/v2 + */ + dashboardDisableSchemaValidationV2?: boolean; + /** + * Log schema validation errors so they can be analyzed later + */ + dashboardSchemaValidationLogging?: boolean; + /** * Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) */ datasourceQueryTypes?: boolean; diff --git a/pkg/registry/apis/dashboard/large_test.go b/pkg/registry/apis/dashboard/large_test.go index 58876d72f96..c9652bf2b59 100644 --- a/pkg/registry/apis/dashboard/large_test.go +++ b/pkg/registry/apis/dashboard/large_test.go @@ -50,7 +50,7 @@ func TestLargeDashboardSupport(t *testing.T) { small, err := json.MarshalIndent(&dash.Spec, "", " ") require.NoError(t, err) require.JSONEq(t, `{ - "schemaVersion": 33, + "schemaVersion": 36, "title": "Panel tests - All panels", "tags": ["gdev","panel-tests","all-panels"] }`, string(small)) diff --git a/pkg/registry/apis/dashboard/mutate.go b/pkg/registry/apis/dashboard/mutate.go index d91ea8fe679..2178aa19341 100644 --- a/pkg/registry/apis/dashboard/mutate.go +++ b/pkg/registry/apis/dashboard/mutate.go @@ -12,6 +12,9 @@ import ( "github.com/grafana/grafana/apps/dashboard/pkg/migration" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" "github.com/grafana/grafana/pkg/apimachinery/utils" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" ) func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) { @@ -28,6 +31,8 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute return err } + var migrationErr error + var resourceInfo utils.ResourceInfo switch v := obj.(type) { case *dashboardV0.Dashboard: delete(v.Spec.Object, "uid") @@ -36,6 +41,7 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute delete(v.Spec.Object, "id") internalID = int64(id) } + resourceInfo = dashboardV0.DashboardResourceInfo case *dashboardV1.Dashboard: delete(v.Spec.Object, "uid") delete(v.Spec.Object, "version") @@ -43,15 +49,16 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute delete(v.Spec.Object, "id") internalID = int64(id) } - // do not error here if the migrations fail - err = migration.Migrate(v.Spec.Object, schemaversion.LATEST_VERSION) - if err != nil { + resourceInfo = dashboardV1.DashboardResourceInfo + migrationErr = migration.Migrate(v.Spec.Object, schemaversion.LATEST_VERSION) + if migrationErr != nil { v.Status.Conversion = &dashboardV1.DashboardConversionStatus{ Failed: true, - Error: err.Error(), + Error: migrationErr.Error(), } } case *dashboardV2.Dashboard: + resourceInfo = dashboardV2.DashboardResourceInfo // Noop for V2 default: return fmt.Errorf("mutation error: expected to dashboard, got %T", obj) @@ -61,5 +68,46 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute meta.SetDeprecatedInternalID(internalID) // nolint:staticcheck } + fieldValidationMode := getFieldValidationMode(a) + + var validationErrorList field.ErrorList + var validationProcessingError error + if migrationErr == nil { + // Migration check passed, validate the spec now - this will respect the field validation mode! + validationErrorList, validationProcessingError = b.ValidateDashboardSpec(ctx, obj, fieldValidationMode) + } + + // Only fail if the field validation mode is strict + if fieldValidationMode == metav1.FieldValidationStrict { + if migrationErr != nil { + return apierrors.NewInvalid(resourceInfo.GroupVersionKind().GroupKind(), meta.GetName(), field.ErrorList{ + field.Invalid(field.NewPath("spec"), meta.GetName(), migrationErr.Error())}) + } + if validationProcessingError != nil { + return validationProcessingError + } + if len(validationErrorList) > 0 { + return apierrors.NewInvalid(resourceInfo.GroupVersionKind().GroupKind(), meta.GetName(), validationErrorList) + } + } + return nil } + +func getFieldValidationMode(a admission.Attributes) string { + var validation string + switch opts := a.GetOperationOptions().(type) { + case *metav1.CreateOptions: + validation = opts.FieldValidation + case *metav1.UpdateOptions: + validation = opts.FieldValidation + default: + validation = metav1.FieldValidationStrict + } + + if validation == "" { + validation = metav1.FieldValidationStrict + } + + return validation +} diff --git a/pkg/registry/apis/dashboard/mutation_test.go b/pkg/registry/apis/dashboard/mutation_test.go index 6e702edde48..44e1fd2986d 100644 --- a/pkg/registry/apis/dashboard/mutation_test.go +++ b/pkg/registry/apis/dashboard/mutation_test.go @@ -22,6 +22,7 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) { operation admission.Operation expectedID int64 migrationExpected bool + expectedError bool }{ { name: "should skip non-create/update operations", @@ -62,7 +63,7 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) { migrationExpected: true, }, { - name: "v1 should not error mutation hook if migration fails", + name: "v1 should error mutation hook if migration fails", inputObj: &dashv1.Dashboard{ Spec: common.Unstructured{ Object: map[string]interface{}{ @@ -71,8 +72,8 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) { }, }, }, - operation: admission.Create, - expectedID: 456, + operation: admission.Create, + expectedError: true, }, } @@ -92,6 +93,11 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) { false, nil, ), nil) + + if tt.expectedError { + require.Error(t, err) + return + } require.NoError(t, err) if tt.operation == admission.Create || tt.operation == admission.Update { diff --git a/pkg/registry/apis/dashboard/schema_validation.go b/pkg/registry/apis/dashboard/schema_validation.go new file mode 100644 index 00000000000..2fa75026ec6 --- /dev/null +++ b/pkg/registry/apis/dashboard/schema_validation.go @@ -0,0 +1,78 @@ +package dashboard + +import ( + "context" + _ "embed" + "errors" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/services/featuremgmt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ValidateDashboardSpec validates the dashboard spec and throws a detailed error if there are validation errors. +func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj runtime.Object, fieldValidationMode string) (field.ErrorList, error) { + // This will be removed with the other PR + return nil, nil + + // Unreachable code is intentional until the code above is removed + //nolint:govet + accessor, err := utils.MetaAccessor(obj) + if err != nil { + return nil, fmt.Errorf("error getting meta accessor: %w", err) + } + + errorOnSchemaMismatches := false + mode := fieldValidationMode + if mode != metav1.FieldValidationIgnore { + switch obj.(type) { + case *v0alpha1.Dashboard: + errorOnSchemaMismatches = false // Never error for v0 + case *v1alpha1.Dashboard: + errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV1) + case *v2alpha1.Dashboard: + errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV2) + default: + return nil, fmt.Errorf("Invalid dashboard type: %T", obj) + } + } + if mode == metav1.FieldValidationWarn { + return nil, errors.New("FieldValidationWarn is not supported") + } + + alwaysLogSchemaValidationErrors := b.features.IsEnabled(ctx, featuremgmt.FlagDashboardSchemaValidationLogging) + + var errors field.ErrorList + var schemaVersionError field.ErrorList + if errorOnSchemaMismatches || alwaysLogSchemaValidationErrors { + switch v := obj.(type) { + case *v0alpha1.Dashboard: + errors, schemaVersionError = v0alpha1.ValidateDashboardSpec(v, alwaysLogSchemaValidationErrors) + case *v1alpha1.Dashboard: + errors, schemaVersionError = v1alpha1.ValidateDashboardSpec(v, alwaysLogSchemaValidationErrors) + case *v2alpha1.Dashboard: + errors = v2alpha1.ValidateDashboardSpec(v) + } + } + + if alwaysLogSchemaValidationErrors && len(errors) > 0 { + b.log.Info("Schema validation errors during dashboard validation", "group_version", obj.GetObjectKind().GroupVersionKind().GroupVersion().String(), "name", accessor.GetName(), "errors", errors.ToAggregate().Error(), "schema_version_mismatch", schemaVersionError != nil) + } + + if errorOnSchemaMismatches { + if schemaVersionError != nil { + return schemaVersionError, nil + } + if len(errors) > 0 { + return errors, nil + } + } + return nil, nil +} diff --git a/pkg/services/apiserver/client/client.go b/pkg/services/apiserver/client/client.go index 9c1e3ea66b4..d4d9ca9a255 100644 --- a/pkg/services/apiserver/client/client.go +++ b/pkg/services/apiserver/client/client.go @@ -24,8 +24,8 @@ import ( type K8sHandler interface { GetNamespace(orgID int64) string Get(ctx context.Context, name string, orgID int64, options v1.GetOptions, subresource ...string) (*unstructured.Unstructured, error) - Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) - Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) + Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts v1.CreateOptions) (*unstructured.Unstructured, error) + Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts v1.UpdateOptions) (*unstructured.Unstructured, error) Delete(ctx context.Context, name string, orgID int64, options v1.DeleteOptions) error DeleteCollection(ctx context.Context, orgID int64) error List(ctx context.Context, orgID int64, options v1.ListOptions) (*unstructured.UnstructuredList, error) @@ -71,22 +71,22 @@ func (h *k8sHandler) Get(ctx context.Context, name string, orgID int64, options return client.Get(ctx, name, options, subresource...) } -func (h *k8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) { +func (h *k8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts v1.CreateOptions) (*unstructured.Unstructured, error) { client, err := h.getClient(ctx, orgID) if err != nil { return nil, err } - return client.Create(ctx, obj, v1.CreateOptions{}) + return client.Create(ctx, obj, opts) } -func (h *k8sHandler) Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) { +func (h *k8sHandler) Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts v1.UpdateOptions) (*unstructured.Unstructured, error) { client, err := h.getClient(ctx, orgID) if err != nil { return nil, err } - return client.Update(ctx, obj, v1.UpdateOptions{}) + return client.Update(ctx, obj, opts) } func (h *k8sHandler) Delete(ctx context.Context, name string, orgID int64, options v1.DeleteOptions) error { diff --git a/pkg/services/apiserver/client/client_mock.go b/pkg/services/apiserver/client/client_mock.go index f17c89daf67..2cc34b89d95 100644 --- a/pkg/services/apiserver/client/client_mock.go +++ b/pkg/services/apiserver/client/client_mock.go @@ -32,16 +32,16 @@ func (m *MockK8sHandler) Get(ctx context.Context, name string, orgID int64, opti return args.Get(0).(*unstructured.Unstructured), args.Error(1) } -func (m *MockK8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) { - args := m.Called(ctx, obj, orgID) +func (m *MockK8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts v1.CreateOptions) (*unstructured.Unstructured, error) { + args := m.Called(ctx, obj, orgID, opts) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*unstructured.Unstructured), args.Error(1) } -func (m *MockK8sHandler) Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) { - args := m.Called(ctx, obj, orgID) +func (m *MockK8sHandler) Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64, opts v1.UpdateOptions) (*unstructured.Unstructured, error) { + args := m.Called(ctx, obj, orgID, opts) if args.Get(0) == nil { return nil, args.Error(1) } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index eed74193348..77251a18e64 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -1833,10 +1833,14 @@ func (dr *DashboardServiceImpl) saveProvisionedDashboardThroughK8s(ctx context.C meta.SetManagerProperties(m) meta.SetSourceProperties(s) - out, err := dr.k8sclient.Update(ctx, obj, cmd.OrgID) + out, err := dr.k8sclient.Update(ctx, obj, cmd.OrgID, v1.UpdateOptions{ + FieldValidation: v1.FieldValidationIgnore, + }) if err != nil && apierrors.IsNotFound(err) { // Create if it doesn't already exist. - out, err = dr.k8sclient.Create(ctx, obj, cmd.OrgID) + out, err = dr.k8sclient.Create(ctx, obj, cmd.OrgID, v1.CreateOptions{ + FieldValidation: v1.FieldValidationIgnore, + }) if err != nil { return nil, err } @@ -1854,10 +1858,14 @@ func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd } dashboard.SetPluginIDMeta(obj, cmd.PluginID) - out, err := dr.k8sclient.Update(ctx, obj, orgID) + out, err := dr.k8sclient.Update(ctx, obj, orgID, v1.UpdateOptions{ + FieldValidation: v1.FieldValidationIgnore, + }) if err != nil && apierrors.IsNotFound(err) { // Create if it doesn't already exist. - out, err = dr.k8sclient.Create(ctx, obj, orgID) + out, err = dr.k8sclient.Create(ctx, obj, orgID, v1.CreateOptions{ + FieldValidation: v1.FieldValidationIgnore, + }) if err != nil { return nil, err } diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 313edeba510..0ff3050f3ee 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -1133,7 +1133,9 @@ func TestUnprovisionDashboard(t *testing.T) { }, }} // should update it to be without annotations - k8sCliMock.On("Update", mock.Anything, dashWithoutAnnotations, mock.Anything).Return(dashWithoutAnnotations, nil) + k8sCliMock.On("Update", mock.Anything, dashWithoutAnnotations, mock.Anything, metav1.UpdateOptions{ + FieldValidation: metav1.FieldValidationIgnore, + }).Return(dashWithoutAnnotations, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{ @@ -1357,7 +1359,9 @@ func TestSaveProvisionedDashboard(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil) k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) - k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, metav1.UpdateOptions{ + FieldValidation: metav1.FieldValidationIgnore, + }).Return(&dashboardUnstructured, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") dashboard, err := service.SaveProvisionedDashboard(ctx, query, &dashboards.DashboardProvisioning{}) @@ -1419,7 +1423,9 @@ func TestSaveDashboard(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") - k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, metav1.UpdateOptions{ + FieldValidation: metav1.FieldValidationIgnore, + }).Return(&dashboardUnstructured, nil) dashboard, err := service.SaveDashboard(ctx, query, false) require.NoError(t, err) @@ -1430,7 +1436,9 @@ func TestSaveDashboard(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") - k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, metav1.UpdateOptions{ + FieldValidation: metav1.FieldValidationIgnore, + }).Return(&dashboardUnstructured, nil) dashboard, err := service.SaveDashboard(ctx, query, false) require.NoError(t, err) @@ -1440,7 +1448,9 @@ func TestSaveDashboard(t *testing.T) { t.Run("Should return an error if uid is invalid", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") - k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, metav1.UpdateOptions{ + FieldValidation: metav1.FieldValidationIgnore, + }).Return(&dashboardUnstructured, nil) query.Dashboard.UID = "invalid/uid" _, err := service.SaveDashboard(ctx, query, false) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 9e211337f3f..12885943168 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -568,6 +568,24 @@ var ( Owner: grafanaAppPlatformSquad, Expression: "true", // enabled by default }, + { + Name: "dashboardDisableSchemaValidationV1", + Description: "Disable schema validation for dashboards/v1", + Stage: FeatureStageExperimental, + Owner: grafanaAppPlatformSquad, + }, + { + Name: "dashboardDisableSchemaValidationV2", + Description: "Disable schema validation for dashboards/v2", + Stage: FeatureStageExperimental, + Owner: grafanaAppPlatformSquad, + }, + { + Name: "dashboardSchemaValidationLogging", + Description: "Log schema validation errors so they can be analyzed later", + Stage: FeatureStageExperimental, + Owner: grafanaAppPlatformSquad, + }, { Name: "datasourceQueryTypes", Description: "Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus)", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 7edec7f5e13..6263e9d0183 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -73,6 +73,9 @@ kubernetesPlaylists,GA,@grafana/grafana-app-platform-squad,false,true,false kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false kubernetesDashboards,experimental,@grafana/grafana-app-platform-squad,false,false,true kubernetesClientDashboardsFolders,GA,@grafana/grafana-app-platform-squad,false,false,false +dashboardDisableSchemaValidationV1,experimental,@grafana/grafana-app-platform-squad,false,false,false +dashboardDisableSchemaValidationV2,experimental,@grafana/grafana-app-platform-squad,false,false,false +dashboardSchemaValidationLogging,experimental,@grafana/grafana-app-platform-squad,false,false,false datasourceQueryTypes,experimental,@grafana/grafana-app-platform-squad,false,true,false queryService,experimental,@grafana/grafana-app-platform-squad,false,true,false queryServiceRewrite,experimental,@grafana/grafana-app-platform-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 4b23d0076e6..1fedab7823d 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -303,6 +303,18 @@ const ( // Route the folder and dashboard service requests to k8s FlagKubernetesClientDashboardsFolders = "kubernetesClientDashboardsFolders" + // FlagDashboardDisableSchemaValidationV1 + // Disable schema validation for dashboards/v1 + FlagDashboardDisableSchemaValidationV1 = "dashboardDisableSchemaValidationV1" + + // FlagDashboardDisableSchemaValidationV2 + // Disable schema validation for dashboards/v2 + FlagDashboardDisableSchemaValidationV2 = "dashboardDisableSchemaValidationV2" + + // FlagDashboardSchemaValidationLogging + // Log schema validation errors so they can be analyzed later + FlagDashboardSchemaValidationLogging = "dashboardSchemaValidationLogging" + // FlagDatasourceQueryTypes // Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) FlagDatasourceQueryTypes = "datasourceQueryTypes" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index a722b4ab073..0fa35053a07 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -731,6 +731,30 @@ "frontend": true } }, + { + "metadata": { + "name": "dashboardDisableSchemaValidationV1", + "resourceVersion": "1744303023863", + "creationTimestamp": "2025-04-10T16:37:03Z" + }, + "spec": { + "description": "Disable schema validation for dashboards/v1", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad" + } + }, + { + "metadata": { + "name": "dashboardDisableSchemaValidationV2", + "resourceVersion": "1744303023863", + "creationTimestamp": "2025-04-10T16:37:03Z" + }, + "spec": { + "description": "Disable schema validation for dashboards/v2", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad" + } + }, { "metadata": { "name": "dashboardNewLayouts", @@ -786,6 +810,18 @@ "expression": "true" } }, + { + "metadata": { + "name": "dashboardSchemaValidationLogging", + "resourceVersion": "1744303223631", + "creationTimestamp": "2025-04-10T16:40:23Z" + }, + "spec": { + "description": "Log schema validation errors so they can be analyzed later", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad" + } + }, { "metadata": { "name": "dashgpt", @@ -1278,6 +1314,32 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "flagDashboardDisableSchemaValidationV1", + "resourceVersion": "1744302136118", + "creationTimestamp": "2025-04-10T16:22:16Z", + "deletionTimestamp": "2025-04-10T16:37:03Z" + }, + "spec": { + "description": "Disable schema validation for dashboards/v1", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad" + } + }, + { + "metadata": { + "name": "flagDashboardDisableSchemaValidationV2", + "resourceVersion": "1744302136118", + "creationTimestamp": "2025-04-10T16:22:16Z", + "deletionTimestamp": "2025-04-10T16:37:03Z" + }, + "spec": { + "description": "Disable schema validation for dashboards/v2", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad" + } + }, { "metadata": { "name": "formatString", diff --git a/pkg/services/folder/folderimpl/unifiedstore.go b/pkg/services/folder/folderimpl/unifiedstore.go index 665ba45dd81..7280303f38e 100644 --- a/pkg/services/folder/folderimpl/unifiedstore.go +++ b/pkg/services/folder/folderimpl/unifiedstore.go @@ -50,8 +50,8 @@ func (ss *FolderUnifiedStoreImpl) Create(ctx context.Context, cmd folder.CreateF if err != nil { return nil, err } - - out, err := ss.k8sclient.Create(ctx, obj, cmd.OrgID) + out, err := ss.k8sclient.Create(ctx, obj, cmd.OrgID, v1.CreateOptions{ + FieldValidation: v1.FieldValidationIgnore}) if err != nil { return nil, err } @@ -106,7 +106,9 @@ func (ss *FolderUnifiedStoreImpl) Update(ctx context.Context, cmd folder.UpdateF meta.SetFolder(*cmd.NewParentUID) } - out, err := ss.k8sclient.Update(ctx, updated, cmd.OrgID) + out, err := ss.k8sclient.Update(ctx, updated, cmd.OrgID, v1.UpdateOptions{ + FieldValidation: v1.FieldValidationIgnore, + }) if err != nil { return nil, err } diff --git a/pkg/services/store/kind/dashboard/testdata/devdash-all-panels-info.json b/pkg/services/store/kind/dashboard/testdata/devdash-all-panels-info.json index 7d6e29aae5e..f97a4796ba6 100644 --- a/pkg/services/store/kind/dashboard/testdata/devdash-all-panels-info.json +++ b/pkg/services/store/kind/dashboard/testdata/devdash-all-panels-info.json @@ -228,7 +228,7 @@ ] } ], - "schemaVersion": 33, + "schemaVersion": 36, "linkCount": 2, "timeFrom": "now-6h", "timeTo": "now", diff --git a/pkg/tests/apis/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go index f6ffedfc601..a630d12bf34 100644 --- a/pkg/tests/apis/dashboard/dashboards_test.go +++ b/pkg/tests/apis/dashboard/dashboards_test.go @@ -42,7 +42,8 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper, gvr schema.Group obj := &unstructured.Unstructured{ Object: map[string]interface{}{ "spec": map[string]any{ - "title": "Test empty dashboard", + "title": "Test empty dashboard", + "schemaVersion": 41, }, }, } diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index 3551f7548f1..540498c91eb 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -793,7 +793,8 @@ func createDashboardObject(t *testing.T, title string, folderUID string, generat }, }, "spec": map[string]interface{}{ - "title": title, + "title": title, + "schemaVersion": 41, }, }, } diff --git a/pkg/tests/apis/dashboard/testdata/dashboard-test-v1.yaml b/pkg/tests/apis/dashboard/testdata/dashboard-test-v1.yaml index 1e17651de60..6ad80f602c6 100644 --- a/pkg/tests/apis/dashboard/testdata/dashboard-test-v1.yaml +++ b/pkg/tests/apis/dashboard/testdata/dashboard-test-v1.yaml @@ -6,3 +6,4 @@ spec: title: Test dashboard. Created at v1 uid: test-v1 # will be removed by mutation hook version: 1234567 # will be removed by mutation hook + schemaVersion: 41