Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80137abe3f | |||
| 97af86efb2 | |||
| f58ab2a6a1 | |||
| b96a1ae722 | |||
| a53875e621 | |||
| 9598ae6434 | |||
| ab0b05550f | |||
| 4518add556 | |||
| 00b89b0d29 | |||
| a3eedfeb73 | |||
| 1e8f1f74ea | |||
| 66b05914e2 | |||
| 0c60d356d1 | |||
| 41d7213d7e | |||
| efad6c7be0 | |||
| e116254f32 | |||
| 2efcc88e62 | |||
| 6fea614106 | |||
| c0c05a65fd | |||
| 41ed2aeb23 | |||
| 9e9233051e | |||
| a5faedbe68 | |||
| 6fee200327 |
+6
-4
@@ -82,8 +82,8 @@ cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2zn
|
||||
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
|
||||
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
|
||||
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
||||
connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw=
|
||||
connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8=
|
||||
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
|
||||
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||
cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWpvALHPLT45nOekHkgOt+IOfutBbPqM17I=
|
||||
cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8=
|
||||
cuelang.org/go v0.11.1 h1:pV+49MX1mmvDm8Qh3Za3M786cty8VKPWzQ1Ho4gZRP0=
|
||||
@@ -749,6 +749,8 @@ github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
|
||||
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/gnostic v0.7.1 h1:t5Kc7j/8kYr8t2u11rykRrPPovlEMG4+xdc/SpekATs=
|
||||
github.com/google/gnostic v0.7.1/go.mod h1:KSw6sxnxEBFM8jLPfJd46xZP+yQcfE8XkiqfZx5zR28=
|
||||
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
|
||||
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
@@ -887,8 +889,8 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604
|
||||
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae h1:35W3Wjp9KWnSoV/DuymmyIj5aHE0CYlDQ5m2KeXUPAc=
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae/go.mod h1:6CJ1uXmLZ13ufpO9xE4pST+DyaBt0uszzrV0YnoaVLQ=
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f h1:fTlIj5n4x5dU63XHItug7GLjtnaeJdPqBlqg4zlABq0=
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f/go.mod h1:VBNcIhunCZsJ3/mcYx+j7uFf0P/108eiWa+8+Z9ll3o=
|
||||
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
|
||||
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
|
||||
github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU=
|
||||
|
||||
@@ -41,9 +41,13 @@ Select a group to expand it and view the list of alert rules within that group.
|
||||
|
||||
The list view includes a number of filters to simplify managing large volumes of alerts.
|
||||
|
||||
## Filter and save searches
|
||||
|
||||
Click the **Filter** button to open the filter popup. You can filter by name, label, folder/namespace, evaluation group, data source, contact point, rule source, rule state, rule type, and the health of the alert rule from the popup menu. Click **Apply** at the bottom of the filter popup to enact the filters as you search.
|
||||
|
||||
{{< figure src="/media/docs/alerting/alerting-list-view-filter.png" max-width="750px" alt="Alert rule filter options" >}}
|
||||
Click the **Saved searches** button to open the list of previously saved searches, or click **+ Save current search** to add your current search to the saved searches list. You can also rename a saved search or set it as a default search. When you set a saved search as the default search, the Alert rules page opens with the search applied.
|
||||
|
||||
{{< figure src="/media/docs/alerting/alerting-saved-searches.png" max-width="750px" alt="Alert rule filter options" >}}
|
||||
|
||||
## Change alert rules list view
|
||||
|
||||
|
||||
@@ -135,9 +135,12 @@ You can use the **Span Limit** field in **Options** section of the TraceQL query
|
||||
This field sets the maximum number of spans to return for each span set.
|
||||
By default, the maximum value that you can set for the **Span Limit** value (or the spss query) is 100.
|
||||
In Tempo configuration, this value is controlled by the `max_spans_per_span_set` parameter and can be modified by your Tempo administrator.
|
||||
Grafana Cloud users can contact Grafana Support to request a change.
|
||||
Entering a value higher than the default results in an error.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Changing the value of `max_spans_per_span_set` isn't supported in Grafana Cloud.
|
||||
{{< /admonition >}}
|
||||
|
||||
### Focus on traces or spans
|
||||
|
||||
Under **Options**, you can choose to display the table as **Traces** or **Spans** focused.
|
||||
|
||||
@@ -23,6 +23,8 @@ killercoda:
|
||||
|
||||
This tutorial is a continuation of the [Get started with Grafana Alerting - Route alerts using dynamic labels](http://www.grafana.com/tutorials/alerting-get-started-pt5/) tutorial.
|
||||
|
||||
{{< youtube id="mqj_hN24zLU" >}}
|
||||
|
||||
<!-- USE CASE -->
|
||||
|
||||
In this tutorial you will learn how to:
|
||||
|
||||
@@ -7,7 +7,7 @@ require (
|
||||
buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.36.2-20250703125925-3f0fcf4bff96.1 // @grafana/observability-traces-and-profiling
|
||||
cloud.google.com/go/kms v1.22.0 // @grafana/grafana-backend-group
|
||||
cloud.google.com/go/storage v1.55.0 // @grafana/grafana-backend-group
|
||||
connectrpc.com/connect v1.18.1 // @grafana/observability-traces-and-profiling
|
||||
connectrpc.com/connect v1.19.1 // @grafana/observability-traces-and-profiling
|
||||
cuelang.org/go v0.11.1 // @grafana/grafana-as-code
|
||||
dario.cat/mergo v1.0.2 // @grafana/grafana-app-platform-squad
|
||||
filippo.io/age v1.2.1 // @grafana/identity-access-team
|
||||
@@ -31,15 +31,17 @@ require (
|
||||
github.com/andybalholm/brotli v1.2.0 // @grafana/partner-datasources
|
||||
github.com/apache/arrow-go/v18 v18.4.1 // @grafana/plugins-platform-backend
|
||||
github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad
|
||||
github.com/aws/aws-sdk-go v1.55.7 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2 v1.40.0 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2/service/oam v1.18.3 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 // @grafana/grafana-operator-experience-squad
|
||||
github.com/aws/smithy-go v1.23.2 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go v1.55.8 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // @grafana/grafana-operator-experience-squad
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.53.0 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.63.0 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.279.0 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2/service/oam v1.23.10 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.5 // @grafana/aws-datasources
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0 // @grafana/grafana-operator-experience-squad
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // @grafana/grafana-operator-experience-squad
|
||||
github.com/aws/smithy-go v1.24.0 // @grafana/aws-datasources
|
||||
github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group
|
||||
github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend
|
||||
github.com/blang/semver/v4 v4.0.0 // indirect; @grafana/grafana-developer-enablement-squad
|
||||
@@ -111,7 +113,7 @@ require (
|
||||
github.com/grafana/nanogit v0.3.0 // indirect; @grafana/grafana-git-ui-sync-team
|
||||
github.com/grafana/otel-profiling-go v0.5.1 // @grafana/grafana-backend-group
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // @grafana/observability-traces-and-profiling
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae // @grafana/observability-traces-and-profiling
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f // @grafana/observability-traces-and-profiling
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/grafana-search-and-storage
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // @grafana/plugins-platform-backend
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // @grafana/grafana-backend-group
|
||||
@@ -341,24 +343,22 @@ require (
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/at-wat/mqtt-go v0.19.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
|
||||
github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
|
||||
@@ -681,6 +681,7 @@ require (
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/google/gnostic v0.7.1 // indirect
|
||||
github.com/gophercloud/gophercloud/v2 v2.9.0 // indirect
|
||||
github.com/grafana/sqlds/v5 v5.0.3 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
|
||||
|
||||
@@ -627,8 +627,8 @@ cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoIS
|
||||
cloud.google.com/go/workflows v1.8.0/go.mod h1:ysGhmEajwZxGn1OhGOGKsTXc5PyxOc0vfKf5Af+to4M=
|
||||
cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT3ujaO/WwSA=
|
||||
cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw=
|
||||
connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw=
|
||||
connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8=
|
||||
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
|
||||
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||
contrib.go.opencensus.io/exporter/ocagent v0.6.0/go.mod h1:zmKjrJcdo0aYcVS7bmEeSEBLPA9YJp5bjrofdU3pIXs=
|
||||
cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWpvALHPLT45nOekHkgOt+IOfutBbPqM17I=
|
||||
cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8=
|
||||
@@ -848,60 +848,60 @@ github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN
|
||||
github.com/aws/aws-sdk-go v1.22.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
|
||||
github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
|
||||
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
|
||||
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY=
|
||||
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
|
||||
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw=
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 h1:Nn3qce+OHZuMj/edx4its32uxedAmquCDxtZkrdeiD4=
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3/go.mod h1:aqsLGsPs+rJfwDBwWHLcIV8F7AFcikFTPLwUD4RwORQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 h1:e5cbPZYTIY2nUEFieZUfVdINOiCTvChOMPfdLnmiLzs=
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0/go.mod h1:UseIHRfrm7PqeZo6fcTb6FUCXzCnh1KJbQbmOfxArGM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 h1:IfMb3Ar8xEaWjgH/zeVHYD8izwJdQgRP5mKCTDt4GNk=
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2/go.mod h1:35jGWx7ECvCwTsApqicFYzZ7JFEnBc6oHUuOQ3xIS54=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.53.0 h1:XY6wKzfriEF+V8bFYFi1S3i8ly+Zetq/RuPyaGdMMzE=
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.53.0/go.mod h1:zUms+kt0awoSYh/MwI9d3AV5xMHIDRf7I736b1Drw/k=
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.63.0 h1:vEc1y56GbepIC0/NsYfFn4splRMNXgJTTG3G1B/6Ov0=
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.63.0/go.mod h1:ESQxVIp7hs1MdsdEF4KITf65SfM3fh/EEiYi+s0S/pE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.279.0 h1:o7eJKe6VYAnqERPlLAvDW5VKXV6eTKv1oxTpMoDP378=
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.279.0/go.mod h1:Wg68QRgy2gEGGdmTPU/UbVpdv8sM14bUZmF64KFwAsY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 h1:zJeUxFP7+XP52u23vrp4zMcVhShTWbNO8dHV6xCSvFo=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.41.2/go.mod h1:Pqd9k4TuespkireN206cK2QBsaBTL6X+VPAez5Qcijk=
|
||||
github.com/aws/aws-sdk-go-v2/service/oam v1.18.3 h1:teOWtElLARLOhpYWwupjLbY9j5I/yZ/H1I8jg41An78=
|
||||
github.com/aws/aws-sdk-go-v2/service/oam v1.18.3/go.mod h1:wGhpdyftHX6/1U4egowHkYdypwBMjpb+KjAAprv6z20=
|
||||
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 h1:PwbxovpcJvb25k019bkibvJfCpCmIANOFrXZIFPmRzk=
|
||||
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6/go.mod h1:Z4xLt5mXspLKjBV92i165wAJ/3T6TIv4n7RtIS8pWV0=
|
||||
github.com/aws/aws-sdk-go-v2/service/oam v1.23.10 h1:bgLzHGeE6HCvd+DmyyBQS4duiyUz+g6hmKl1kunaeP4=
|
||||
github.com/aws/aws-sdk-go-v2/service/oam v1.23.10/go.mod h1:jEr64iJ6XD0MkzzW3VqqPX7Cws9MLHhLo+sMCoBC7B8=
|
||||
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.5 h1:0jwTqyyPsbn4UysC6ltj/AuntNBWBeU++kNJQtShtg0=
|
||||
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.5/go.mod h1:ydy76wx7I+HsqhlEo0vhVTl785TDNbpgtEXhd3i4ZTc=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZgtAXTND1Kk9OXg=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU=
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 h1:w6a0H79HrHf3lr+zrw+pSzR5B+caiQFAKiNHlrUcnoc=
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1/go.mod h1:c6Vg0BRiU7v0MVhHupw90RyL120QBwAMLbDCzptGeMk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk=
|
||||
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
|
||||
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0 h1:vL6rQXcGtFv9q/9eRPdI+lL+dvTm7xKGZYSHEvmrpDk=
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0/go.mod h1:QwEDLD+7EukuEUnbWtiNE8LhgvvmhjZoi4XAppYPtyc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/axiomhq/hyperloglog v0.0.0-20191112132149-a4c4c47bc57f/go.mod h1:2stgcRjl6QmW+gU2h5E7BQXg4HU0gzxKWDuT5HviN9s=
|
||||
github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 h1:60m4tnanN1ctzIu4V3bfCNJ39BiOPSm1gHFlFjTkRE0=
|
||||
github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c=
|
||||
@@ -1503,6 +1503,8 @@ github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PU
|
||||
github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/gnostic v0.7.1 h1:t5Kc7j/8kYr8t2u11rykRrPPovlEMG4+xdc/SpekATs=
|
||||
github.com/google/gnostic v0.7.1/go.mod h1:KSw6sxnxEBFM8jLPfJd46xZP+yQcfE8XkiqfZx5zR28=
|
||||
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
|
||||
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
@@ -1685,8 +1687,8 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604
|
||||
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae h1:35W3Wjp9KWnSoV/DuymmyIj5aHE0CYlDQ5m2KeXUPAc=
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae/go.mod h1:6CJ1uXmLZ13ufpO9xE4pST+DyaBt0uszzrV0YnoaVLQ=
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f h1:fTlIj5n4x5dU63XHItug7GLjtnaeJdPqBlqg4zlABq0=
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f/go.mod h1:VBNcIhunCZsJ3/mcYx+j7uFf0P/108eiWa+8+Z9ll3o=
|
||||
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
|
||||
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
|
||||
github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56 h1:SDGrP81Vcd102L3UJEryRd1eestRw73wt+b8vnVEFe0=
|
||||
|
||||
@@ -755,6 +755,8 @@ github.com/felixge/fgprof v0.9.4/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZP
|
||||
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/flosch/pongo2/v4 v4.0.2 h1:gv+5Pe3vaSVmiJvh/BZa82b7/00YUGm0PIyVVLop0Hw=
|
||||
github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8=
|
||||
github.com/flowstack/go-jsonschema v0.1.1 h1:dCrjGJRXIlbDsLAgTJZTjhwUJnnxVWl1OgNyYh5nyDc=
|
||||
github.com/flowstack/go-jsonschema v0.1.1/go.mod h1:yL7fNggx1o8rm9RlgXv7hTBWxdBM0rVwpMwimd3F3N0=
|
||||
github.com/fluent/fluent-bit-go v0.0.0-20230731091245-a7a013e2473c h1:yKN46XJHYC/gvgH2UsisJ31+n4K3S7QYZSfU2uAWjuI=
|
||||
github.com/fluent/fluent-bit-go v0.0.0-20230731091245-a7a013e2473c/go.mod h1:L92h+dgwElEyUuShEwjbiHjseW410WIcNz+Bjutc8YQ=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
|
||||
@@ -165,9 +165,17 @@ describe('DateMath', () => {
|
||||
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf());
|
||||
});
|
||||
|
||||
it('should handle multiple math expressions', () => {
|
||||
const date = dateMath.parseDateMath('-2d-6h', dateTime([2014, 1, 5]));
|
||||
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 2, 18]).valueOf());
|
||||
it.each([
|
||||
['-2d-6h', [2014, 1, 5], [2014, 1, 2, 18]],
|
||||
['-30m-2d', [2014, 1, 5], [2014, 1, 2, 23, 30]],
|
||||
['-2d-1d', [2014, 1, 5], [2014, 1, 2]],
|
||||
['-1h-30m', [2014, 1, 5, 12, 0], [2014, 1, 5, 10, 30]],
|
||||
['-1d-1h-30m', [2014, 1, 5, 12, 0], [2014, 1, 4, 10, 30]],
|
||||
['+1d-6h', [2014, 1, 5], [2014, 1, 5, 18]],
|
||||
['-1w-1d', [2014, 1, 14], [2014, 1, 6]],
|
||||
])('should handle multiple math expressions: %s', (expression, inputDate, expectedDate) => {
|
||||
const date = dateMath.parseDateMath(expression, dateTime(inputDate));
|
||||
expect(date!.valueOf()).toEqual(dateTime(expectedDate).valueOf());
|
||||
});
|
||||
|
||||
it('should return false when invalid expression', () => {
|
||||
|
||||
+4
-4
@@ -400,10 +400,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
tableSharedCrosshair?: boolean;
|
||||
/**
|
||||
* Use the kubernetes API for feature toggle management in the frontend
|
||||
*/
|
||||
kubernetesFeatureToggles?: boolean;
|
||||
/**
|
||||
* Enabled grafana cloud specific RBAC roles
|
||||
*/
|
||||
cloudRBACRoles?: boolean;
|
||||
@@ -1263,4 +1259,8 @@ export interface FeatureToggles {
|
||||
* Enables the creation of keepers that manage secrets stored on AWS secrets manager
|
||||
*/
|
||||
secretsManagementAppPlatformAwsKeeper?: boolean;
|
||||
/**
|
||||
* Enables profiles exemplars support in profiles drilldown
|
||||
*/
|
||||
profilesExemplars?: boolean;
|
||||
}
|
||||
|
||||
+5
@@ -25,6 +25,10 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
|
||||
* Allows to group the results.
|
||||
*/
|
||||
groupBy: Array<string>;
|
||||
/**
|
||||
* If set to true, exemplars will be requested
|
||||
*/
|
||||
includeExemplars: boolean;
|
||||
/**
|
||||
* Specifies the query label selectors.
|
||||
*/
|
||||
@@ -49,6 +53,7 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
|
||||
|
||||
export const defaultGrafanaPyroscopeDataQuery: Partial<GrafanaPyroscopeDataQuery> = {
|
||||
groupBy: [],
|
||||
includeExemplars: false,
|
||||
labelSelector: '{}',
|
||||
spanSelector: [],
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useId, memo, HTMLAttributes, ReactNode, SVGProps } from 'react';
|
||||
import { useId, memo, HTMLAttributes, SVGProps } from 'react';
|
||||
|
||||
import { FieldDisplay } from '@grafana/data';
|
||||
|
||||
import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors';
|
||||
import { RadialArcPathEndpointMarks } from './RadialArcPathEndpointMarks';
|
||||
import { getBarEndcapColors, getGradientCss } from './colors';
|
||||
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
|
||||
import { drawRadialArcPath, toRad } from './utils';
|
||||
|
||||
@@ -29,11 +30,6 @@ interface RadialArcPathPropsWithGradient extends RadialArcPathPropsBase {
|
||||
|
||||
type RadialArcPathProps = RadialArcPathPropsWithColor | RadialArcPathPropsWithGradient;
|
||||
|
||||
const ENDPOINT_MARKER_MIN_ANGLE = 10;
|
||||
const DOT_OPACITY = 0.5;
|
||||
const DOT_RADIUS_FACTOR = 0.4;
|
||||
const MAX_DOT_RADIUS = 8;
|
||||
|
||||
export const RadialArcPath = memo(
|
||||
({
|
||||
arcLengthDeg,
|
||||
@@ -68,67 +64,25 @@ export const RadialArcPath = memo(
|
||||
const xEnd = centerX + radius * Math.cos(endRadians);
|
||||
const yEnd = centerY + radius * Math.sin(endRadians);
|
||||
|
||||
const dotRadius =
|
||||
endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2;
|
||||
|
||||
const bgDivStyle: HTMLAttributes<HTMLDivElement>['style'] = { width: boxSize, height: vizHeight, marginLeft: boxX };
|
||||
|
||||
const pathProps: SVGProps<SVGPathElement> = {};
|
||||
let barEndcapColors: [string, string] | undefined;
|
||||
let endpointMarks: ReactNode = null;
|
||||
if (isGradient) {
|
||||
bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape);
|
||||
|
||||
if (endpointMarker && (rest.gradient?.length ?? 0) > 0) {
|
||||
switch (endpointMarker) {
|
||||
case 'point':
|
||||
const [pointColorStart, pointColorEnd] = getEndpointMarkerColors(
|
||||
rest.gradient!,
|
||||
fieldDisplay.display.percent
|
||||
);
|
||||
endpointMarks = (
|
||||
<>
|
||||
{arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && (
|
||||
<circle cx={xStart} cy={yStart} r={dotRadius} fill={pointColorStart} opacity={DOT_OPACITY} />
|
||||
)}
|
||||
<circle cx={xEnd} cy={yEnd} r={dotRadius} fill={pointColorEnd} opacity={DOT_OPACITY} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case 'glow':
|
||||
const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE);
|
||||
const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle);
|
||||
const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle);
|
||||
endpointMarks =
|
||||
arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE ? (
|
||||
<path
|
||||
d={['M', xStartMark, yStartMark, 'A', radius, radius, 0, 0, 1, xEnd, yEnd].join(' ')}
|
||||
fill="none"
|
||||
strokeWidth={barWidth}
|
||||
stroke={endpointMarkerGlowFilter}
|
||||
strokeLinecap={roundedBars ? 'round' : 'butt'}
|
||||
filter={glowFilter}
|
||||
/>
|
||||
) : null;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (barEndcaps) {
|
||||
barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent);
|
||||
}
|
||||
|
||||
pathProps.fill = 'none';
|
||||
pathProps.stroke = 'white';
|
||||
} else {
|
||||
bgDivStyle.backgroundColor = rest.color;
|
||||
|
||||
pathProps.fill = 'none';
|
||||
pathProps.stroke = rest.color;
|
||||
}
|
||||
|
||||
let barEndcapColors: [string, string] | undefined;
|
||||
if (barEndcaps) {
|
||||
barEndcapColors = isGradient
|
||||
? getBarEndcapColors(rest.gradient, fieldDisplay.display.percent)
|
||||
: [rest.color, rest.color];
|
||||
}
|
||||
|
||||
const pathEl = (
|
||||
<path d={path} strokeWidth={barWidth} strokeLinecap={roundedBars ? 'round' : 'butt'} {...pathProps} />
|
||||
);
|
||||
@@ -158,7 +112,23 @@ export const RadialArcPath = memo(
|
||||
)}
|
||||
</g>
|
||||
|
||||
{endpointMarks}
|
||||
{endpointMarker && (
|
||||
<RadialArcPathEndpointMarks
|
||||
startAngle={angle}
|
||||
arcLengthDeg={arcLengthDeg}
|
||||
dimensions={dimensions}
|
||||
endpointMarker={endpointMarker}
|
||||
fieldDisplay={fieldDisplay}
|
||||
xStart={xStart}
|
||||
xEnd={xEnd}
|
||||
yStart={yStart}
|
||||
yEnd={yEnd}
|
||||
roundedBars={roundedBars}
|
||||
endpointMarkerGlowFilter={endpointMarkerGlowFilter}
|
||||
glowFilter={glowFilter}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { render, RenderResult } from '@testing-library/react';
|
||||
|
||||
import { FieldDisplay } from '@grafana/data';
|
||||
|
||||
import { RadialArcPathEndpointMarks, RadialArcPathEndpointMarksProps } from './RadialArcPathEndpointMarks';
|
||||
import { RadialGaugeDimensions } from './types';
|
||||
|
||||
const ser = new XMLSerializer();
|
||||
|
||||
const expectHTML = (result: RenderResult, expected: string) => {
|
||||
let actual = ser.serializeToString(result.asFragment()).replace(/xmlns=".*?" /g, '');
|
||||
expect(actual).toEqual(expected.replace(/^\s*|\n/gm, ''));
|
||||
};
|
||||
|
||||
describe('RadialArcPathEndpointMarks', () => {
|
||||
const defaultDimensions = Object.freeze({
|
||||
centerX: 100,
|
||||
centerY: 100,
|
||||
radius: 80,
|
||||
barWidth: 20,
|
||||
vizWidth: 200,
|
||||
vizHeight: 200,
|
||||
margin: 10,
|
||||
barIndex: 0,
|
||||
thresholdsBarRadius: 0,
|
||||
thresholdsBarWidth: 0,
|
||||
thresholdsBarSpacing: 0,
|
||||
scaleLabelsFontSize: 0,
|
||||
scaleLabelsSpacing: 0,
|
||||
scaleLabelsRadius: 0,
|
||||
gaugeBottomY: 0,
|
||||
}) satisfies RadialGaugeDimensions;
|
||||
|
||||
const defaultFieldDisplay = Object.freeze({
|
||||
name: 'Test',
|
||||
field: {},
|
||||
display: { text: '50', numeric: 50, color: '#FF0000' },
|
||||
hasLinks: false,
|
||||
}) satisfies FieldDisplay;
|
||||
|
||||
const defaultProps = Object.freeze({
|
||||
arcLengthDeg: 90,
|
||||
dimensions: defaultDimensions,
|
||||
fieldDisplay: defaultFieldDisplay,
|
||||
startAngle: 0,
|
||||
xStart: 100,
|
||||
xEnd: 150,
|
||||
yStart: 100,
|
||||
yEnd: 50,
|
||||
}) satisfies Omit<RadialArcPathEndpointMarksProps, 'color' | 'gradient' | 'endpointMarker'>;
|
||||
|
||||
it('renders the expected marks when endpointMarker is "point" w/ a static color', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
<RadialArcPathEndpointMarks {...defaultProps} endpointMarker="point" color="#FF0000" />
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"><circle cx=\"100\" cy=\"100\" r=\"4\" fill=\"#111217\" opacity=\"0.5\"/><circle cx=\"150\" cy=\"50\" r=\"4\" fill=\"#111217\" opacity=\"0.5\"/></svg>'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the expected marks when endpointMarker is "point" w/ a gradient color', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
<RadialArcPathEndpointMarks
|
||||
{...defaultProps}
|
||||
endpointMarker="point"
|
||||
gradient={[
|
||||
{ color: '#00FF00', percent: 0 },
|
||||
{ color: '#0000FF', percent: 1 },
|
||||
]}
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"><circle cx=\"100\" cy=\"100\" r=\"4\" fill=\"#111217\" opacity=\"0.5\"/><circle cx=\"150\" cy=\"50\" r=\"4\" fill=\"#fbfbfb\" opacity=\"0.5\"/></svg>'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the expected marks when endpointMarker is "glow" w/ a static color', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
<RadialArcPathEndpointMarks {...defaultProps} endpointMarker="glow" color="#FF0000" />
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"><path d=\"M 113.89185421335443 21.215379759023364 A 80 80 0 0 1 150 50\" fill=\"none\" stroke-width=\"20\" stroke-linecap=\"butt\"/></svg>'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the expected marks when endpointMarker is "glow" w/ a gradient color', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
<RadialArcPathEndpointMarks
|
||||
{...defaultProps}
|
||||
endpointMarker="glow"
|
||||
gradient={[
|
||||
{ color: '#00FF00', percent: 0 },
|
||||
{ color: '#0000FF', percent: 1 },
|
||||
]}
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"><path d=\"M 113.89185421335443 21.215379759023364 A 80 80 0 0 1 150 50\" fill=\"none\" stroke-width=\"20\" stroke-linecap=\"butt\"/></svg>'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render the start mark when arcLengthDeg is less than the minimum angle for "point" endpointMarker', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
<RadialArcPathEndpointMarks {...defaultProps} arcLengthDeg={5} endpointMarker="point" color="#FF0000" />
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"><circle cx=\"150\" cy=\"50\" r=\"4\" fill=\"#111217\" opacity=\"0.5\"/></svg>'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render anything when arcLengthDeg is less than the minimum angle for "glow" endpointMarker', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
<RadialArcPathEndpointMarks {...defaultProps} arcLengthDeg={5} endpointMarker="glow" color="#FF0000" />
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"/>'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render anything if endpointMarker is some other value', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
{/* @ts-ignore: confirming the component doesn't throw */}
|
||||
<RadialArcPathEndpointMarks {...defaultProps} endpointMarker="foo" />
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"/>'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { FieldDisplay } from '@grafana/data';
|
||||
|
||||
import { getEndpointMarkerColors, getGuideDotColor } from './colors';
|
||||
import { GradientStop, RadialGaugeDimensions } from './types';
|
||||
import { toRad } from './utils';
|
||||
|
||||
interface RadialArcPathEndpointMarksPropsBase {
|
||||
arcLengthDeg: number;
|
||||
dimensions: RadialGaugeDimensions;
|
||||
fieldDisplay: FieldDisplay;
|
||||
endpointMarker: 'point' | 'glow';
|
||||
roundedBars?: boolean;
|
||||
startAngle: number;
|
||||
glowFilter?: string;
|
||||
endpointMarkerGlowFilter?: string;
|
||||
xStart: number;
|
||||
xEnd: number;
|
||||
yStart: number;
|
||||
yEnd: number;
|
||||
}
|
||||
|
||||
interface RadialArcPathEndpointMarksPropsWithColor extends RadialArcPathEndpointMarksPropsBase {
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface RadialArcPathEndpointMarksPropsWithGradient extends RadialArcPathEndpointMarksPropsBase {
|
||||
gradient: GradientStop[];
|
||||
}
|
||||
|
||||
export type RadialArcPathEndpointMarksProps =
|
||||
| RadialArcPathEndpointMarksPropsWithColor
|
||||
| RadialArcPathEndpointMarksPropsWithGradient;
|
||||
|
||||
const ENDPOINT_MARKER_MIN_ANGLE = 10;
|
||||
const DOT_OPACITY = 0.5;
|
||||
const DOT_RADIUS_FACTOR = 0.4;
|
||||
const MAX_DOT_RADIUS = 8;
|
||||
|
||||
export function RadialArcPathEndpointMarks({
|
||||
startAngle: angle,
|
||||
arcLengthDeg,
|
||||
dimensions,
|
||||
endpointMarker,
|
||||
fieldDisplay,
|
||||
xStart,
|
||||
xEnd,
|
||||
yStart,
|
||||
yEnd,
|
||||
roundedBars,
|
||||
endpointMarkerGlowFilter,
|
||||
glowFilter,
|
||||
...rest
|
||||
}: RadialArcPathEndpointMarksProps) {
|
||||
const isGradient = 'gradient' in rest;
|
||||
const { radius, centerX, centerY, barWidth } = dimensions;
|
||||
const endRadians = toRad(angle + arcLengthDeg);
|
||||
|
||||
switch (endpointMarker) {
|
||||
case 'point': {
|
||||
const [pointColorStart, pointColorEnd] = isGradient
|
||||
? getEndpointMarkerColors(rest.gradient, fieldDisplay.display.percent)
|
||||
: [getGuideDotColor(rest.color), getGuideDotColor(rest.color)];
|
||||
|
||||
const dotRadius =
|
||||
endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
{arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && (
|
||||
<circle cx={xStart} cy={yStart} r={dotRadius} fill={pointColorStart} opacity={DOT_OPACITY} />
|
||||
)}
|
||||
<circle cx={xEnd} cy={yEnd} r={dotRadius} fill={pointColorEnd} opacity={DOT_OPACITY} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'glow':
|
||||
const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE);
|
||||
const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle);
|
||||
const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle);
|
||||
if (arcLengthDeg <= ENDPOINT_MARKER_MIN_ANGLE) {
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<path
|
||||
d={['M', xStartMark, yStartMark, 'A', radius, radius, 0, 0, 1, xEnd, yEnd].join(' ')}
|
||||
fill="none"
|
||||
strokeWidth={barWidth}
|
||||
stroke={endpointMarkerGlowFilter}
|
||||
strokeLinecap={roundedBars ? 'round' : 'butt'}
|
||||
filter={glowFilter}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -175,7 +175,7 @@ export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape
|
||||
const GRAY_05 = '#111217';
|
||||
const GRAY_90 = '#fbfbfb';
|
||||
const CONTRAST_THRESHOLD_MAX = 4.5;
|
||||
const getGuideDotColor = (color: string): string => {
|
||||
export const getGuideDotColor = (color: string): string => {
|
||||
const darkColor = GRAY_05;
|
||||
const lightColor = GRAY_90;
|
||||
return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor;
|
||||
|
||||
@@ -204,7 +204,7 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response.
|
||||
func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "GetDataSourceByUID"), time.Since(start).Seconds())
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("GetDataSourceByUID"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.GetOrgID())
|
||||
@@ -240,7 +240,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Re
|
||||
func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "DeleteDataSourceByUID"), time.Since(start).Seconds())
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("DeleteDataSourceByUID"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
uid := web.Params(c.Req)[":uid"]
|
||||
@@ -375,7 +375,7 @@ func validateJSONData(jsonData *simplejson.Json, cfg *setting.Cfg) error {
|
||||
func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "AddDataSource"), time.Since(start).Seconds())
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("AddDataSource"), time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
cmd := datasources.AddDataSourceCommand{}
|
||||
@@ -497,7 +497,7 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.
|
||||
func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "UpdateDataSourceByUID"), time.Since(start).Seconds())
|
||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("UpdateDataSourceByUID"), time.Since(start).Seconds())
|
||||
}()
|
||||
cmd := datasources.UpdateDataSourceCommand{}
|
||||
if err := web.Bind(c.Req, &cmd); err != nil {
|
||||
|
||||
@@ -91,7 +91,7 @@ func setupDsConfigHandlerMetrics() (prometheus.Registerer, *prometheus.Histogram
|
||||
Namespace: "grafana",
|
||||
Name: "ds_config_handler_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by datasource configuration handlers",
|
||||
}, []string{"code_path", "handler"})
|
||||
}, []string{"handler"})
|
||||
promRegister.MustRegister(dsConfigHandlerRequestsDuration)
|
||||
return promRegister, dsConfigHandlerRequestsDuration
|
||||
}
|
||||
|
||||
@@ -387,7 +387,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
||||
Namespace: "grafana",
|
||||
Name: "ds_config_handler_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by datasource configuration handlers",
|
||||
}, []string{"code_path", "handler"}),
|
||||
}, []string{"handler"}),
|
||||
}
|
||||
|
||||
promRegister.MustRegister(hs.htmlHandlerRequestsDuration)
|
||||
|
||||
@@ -928,9 +928,10 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *contextmodel.ReqContext, cfg
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
features := featuremgmt.WithFeatures()
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
||||
&actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
||||
require.NoError(t, err)
|
||||
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features)
|
||||
require.NoError(t, err)
|
||||
@@ -1050,9 +1051,11 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, secrets
|
||||
var routes []*plugins.Route
|
||||
features := featuremgmt.WithFeatures()
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
||||
var sqlStore db.DB = nil
|
||||
dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := datasourceservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
||||
&actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
||||
require.NoError(t, err)
|
||||
proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features)
|
||||
require.NoError(t, err)
|
||||
@@ -1106,9 +1109,11 @@ func setupDSProxyTest(t *testing.T, ctx *contextmodel.ReqContext, ds *datasource
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(dbtest.NewFakeDB(), secretsService, log.NewNopLogger())
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
||||
var sqlStore db.DB = nil
|
||||
dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := datasourceservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
||||
&actest.FakePermissionsService{}, quotatest.New(false, nil), &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
tracer := tracing.InitializeTracerForTest()
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
_ "github.com/Azure/azure-sdk-for-go/services/keyvault/v7.1/keyvault"
|
||||
_ "github.com/Azure/go-autorest/autorest"
|
||||
_ "github.com/Azure/go-autorest/autorest/adal"
|
||||
_ "github.com/aws/aws-sdk-go-v2/credentials"
|
||||
_ "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
|
||||
_ "github.com/aws/aws-sdk-go-v2/service/sts"
|
||||
_ "github.com/beevik/etree"
|
||||
_ "github.com/blugelabs/bluge"
|
||||
_ "github.com/blugelabs/bluge_segment_api"
|
||||
@@ -46,7 +49,6 @@ import (
|
||||
_ "sigs.k8s.io/randfill"
|
||||
_ "xorm.io/builder"
|
||||
|
||||
_ "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
|
||||
_ "github.com/grafana/authlib/authn"
|
||||
_ "github.com/grafana/authlib/authz"
|
||||
_ "github.com/grafana/authlib/cache"
|
||||
|
||||
@@ -209,7 +209,7 @@ func (ots *TracingService) initSampler() (tracesdk.Sampler, error) {
|
||||
case "rateLimiting":
|
||||
return newRateLimiter(ots.cfg.SamplerParam), nil
|
||||
case "remote":
|
||||
return jaegerremote.New("grafana",
|
||||
return jaegerremote.New(ots.cfg.ServiceName,
|
||||
jaegerremote.WithSamplingServerURL(ots.cfg.SamplerRemoteURL),
|
||||
jaegerremote.WithInitialSampler(tracesdk.TraceIDRatioBased(ots.cfg.SamplerParam)),
|
||||
), nil
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.12.0 // indirect
|
||||
github.com/apache/arrow-go/v18 v18.4.1 // indirect
|
||||
github.com/aws/aws-sdk-go v1.55.7 // indirect
|
||||
github.com/aws/aws-sdk-go v1.55.8 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@ github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc=
|
||||
github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
|
||||
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
|
||||
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps=
|
||||
|
||||
@@ -122,7 +122,6 @@ type DashboardsAPIBuilder struct {
|
||||
publicDashboardService publicdashboards.Service
|
||||
snapshotService dashboardsnapshots.Service
|
||||
snapshotOptions dashv0.SnapshotSharingOptions
|
||||
snapshotStorage rest.Storage // for dual-write support in routes
|
||||
namespacer request.NamespaceMapper
|
||||
dashboardActivityChannel live.DashboardActivityChannel
|
||||
isStandalone bool // skips any handling including anything to do with legacy storage
|
||||
@@ -748,26 +747,15 @@ func (b *DashboardsAPIBuilder) storageForVersion(
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshots - only v0alpha1
|
||||
// Legacy only (for now) and only v0alpha1
|
||||
if snapshots != nil && dashboards.GroupVersion().Version == "v0alpha1" {
|
||||
snapshotLegacyStore := &snapshot.SnapshotLegacyStore{
|
||||
ResourceInfo: *snapshots,
|
||||
Service: b.snapshotService,
|
||||
Namespacer: b.namespacer,
|
||||
}
|
||||
|
||||
unifiedSnapshotStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, *snapshots, opts.OptsGetter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshotGr := snapshots.GroupResource()
|
||||
snapshotDualWrite, err := opts.DualWriteBuilder(snapshotGr, snapshotLegacyStore, unifiedSnapshotStore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storage[snapshots.StoragePath()] = snapshotDualWrite
|
||||
b.snapshotStorage = snapshotDualWrite // store for use in routes
|
||||
storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(snapshotDualWrite)
|
||||
storage[snapshots.StoragePath()] = snapshotLegacyStore
|
||||
storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(dashboards, b.snapshotService)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -991,9 +979,7 @@ func (b *DashboardsAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.API
|
||||
|
||||
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
|
||||
searchAPIRoutes := b.search.GetAPIRoutes(defs)
|
||||
snapshotAPIRoutes := snapshot.GetRoutes(b.snapshotService, b.snapshotOptions, defs, func() rest.Storage {
|
||||
return b.snapshotStorage
|
||||
})
|
||||
snapshotAPIRoutes := snapshot.GetRoutes(b.snapshotService, b.snapshotOptions, defs)
|
||||
|
||||
return &builder.APIRoutes{
|
||||
Namespace: append(searchAPIRoutes.Namespace, snapshotAPIRoutes.Namespace...),
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
dashV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
@@ -60,10 +59,7 @@ func convertSnapshotToK8sResource(v *dashboardsnapshots.DashboardSnapshot, names
|
||||
Namespace: namespacer(v.OrgID),
|
||||
},
|
||||
Spec: dashV0.SnapshotSpec{
|
||||
Title: &v.Name,
|
||||
Expires: &expires,
|
||||
External: &v.External,
|
||||
ExternalUrl: &v.ExternalURL,
|
||||
Title: &v.Name,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -82,68 +78,3 @@ func convertSnapshotToK8sResource(v *dashboardsnapshots.DashboardSnapshot, names
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
// convertK8sResourceToCreateCommand converts a K8s Snapshot to a CreateDashboardSnapshotCommand
|
||||
func convertK8sResourceToCreateCommand(snap *dashV0.Snapshot, orgID int64, userID int64) *dashboardsnapshots.CreateDashboardSnapshotCommand {
|
||||
cmd := &dashboardsnapshots.CreateDashboardSnapshotCommand{
|
||||
OrgID: orgID,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
// Map title
|
||||
if snap.Spec.Title != nil {
|
||||
cmd.Name = *snap.Spec.Title
|
||||
}
|
||||
|
||||
// Map dashboard (convert map[string]interface{} to *common.Unstructured)
|
||||
if snap.Spec.Dashboard != nil {
|
||||
cmd.Dashboard = &common.Unstructured{Object: snap.Spec.Dashboard}
|
||||
}
|
||||
|
||||
// Map expires
|
||||
if snap.Spec.Expires != nil {
|
||||
cmd.Expires = *snap.Spec.Expires
|
||||
}
|
||||
|
||||
// Map external settings
|
||||
if snap.Spec.External != nil && *snap.Spec.External {
|
||||
cmd.External = true
|
||||
if snap.Spec.ExternalUrl != nil {
|
||||
cmd.ExternalURL = *snap.Spec.ExternalUrl
|
||||
}
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// convertCreateCmdToK8sSnapshot converts a CreateDashboardSnapshotCommand request to a K8s Snapshot
|
||||
// Used by routes.go to create a Snapshot object from the incoming create command
|
||||
func convertCreateCmdToK8sSnapshot(cmd *dashboardsnapshots.CreateDashboardSnapshotCommand, namespace string) *dashV0.Snapshot {
|
||||
snap := &dashV0.Snapshot{
|
||||
TypeMeta: dashV0.SnapshotResourceInfo.TypeMeta(),
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: namespace,
|
||||
},
|
||||
Spec: dashV0.SnapshotSpec{
|
||||
Title: &cmd.Name,
|
||||
},
|
||||
}
|
||||
|
||||
// Convert *common.Unstructured to map[string]interface{}
|
||||
if cmd.Dashboard != nil {
|
||||
snap.Spec.Dashboard = cmd.Dashboard.Object
|
||||
}
|
||||
|
||||
if cmd.Expires > 0 {
|
||||
snap.Spec.Expires = &cmd.Expires
|
||||
}
|
||||
|
||||
if cmd.External {
|
||||
snap.Spec.External = &cmd.External
|
||||
if cmd.ExternalURL != "" {
|
||||
snap.Spec.ExternalUrl = &cmd.ExternalURL
|
||||
}
|
||||
}
|
||||
|
||||
return snap
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
k8srequest "k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
@@ -17,7 +14,6 @@ import (
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
@@ -26,7 +22,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharingOptions, defs map[string]common.OpenAPIDefinition, storageGetter func() rest.Storage) *builder.APIRoutes {
|
||||
func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharingOptions, defs map[string]common.OpenAPIDefinition) *builder.APIRoutes {
|
||||
prefix := dashv0.SnapshotResourceInfo.GroupResource().Resource
|
||||
tags := []string{dashv0.SnapshotResourceInfo.GroupVersionKind().Kind}
|
||||
|
||||
@@ -101,10 +97,9 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin
|
||||
},
|
||||
},
|
||||
Handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user, err := identity.GetRequester(ctx)
|
||||
user, err := identity.GetRequester(r.Context())
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, err, w)
|
||||
errhttp.Write(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
wrap := &contextmodel.ReqContext{
|
||||
@@ -112,15 +107,11 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin
|
||||
Req: r,
|
||||
Resp: web.NewResponseWriter(r.Method, w),
|
||||
},
|
||||
// SignedInUser: user, ????????????
|
||||
}
|
||||
|
||||
if !options.SnapshotsEnabled {
|
||||
wrap.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
|
||||
return
|
||||
}
|
||||
vars := mux.Vars(r)
|
||||
namespace := vars["namespace"]
|
||||
info, err := authlib.ParseNamespace(namespace)
|
||||
info, err := authlib.ParseNamespace(vars["namespace"])
|
||||
if err != nil {
|
||||
wrap.JsonApiErr(http.StatusBadRequest, "expected namespace", nil)
|
||||
return
|
||||
@@ -137,82 +128,8 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin
|
||||
return
|
||||
}
|
||||
|
||||
if cmd.External && !options.ExternalEnabled {
|
||||
wrap.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// fill cmd data
|
||||
if cmd.Name == "" {
|
||||
cmd.Name = "Unnamed snapshot"
|
||||
}
|
||||
cmd.OrgID = user.GetOrgID()
|
||||
cmd.UserID, _ = identity.UserIdentifier(user.GetID())
|
||||
|
||||
//originalDashboardURL, err := dashboardsnapshots.CreateOriginalDashboardURL(&cmd)
|
||||
|
||||
// TODO: add logic for external and internal snapshots
|
||||
if cmd.External {
|
||||
// TODO: if it is an external dashboard make a POST to the public snapshot server
|
||||
} else {
|
||||
|
||||
}
|
||||
|
||||
// TODO: validate dashboard exists. Need to call dashboards api, Maybe in a validation hook?
|
||||
|
||||
storage := storageGetter()
|
||||
if storage == nil {
|
||||
errhttp.Write(ctx, fmt.Errorf("snapshot storage not available"), w)
|
||||
return
|
||||
}
|
||||
creater, ok := storage.(rest.Creater)
|
||||
if !ok {
|
||||
errhttp.Write(ctx, fmt.Errorf("snapshot storage does not support create"), w)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert command to K8s Snapshot
|
||||
snapshot := convertCreateCmdToK8sSnapshot(&cmd, namespace)
|
||||
|
||||
snapshot.SetGenerateName("snapshot-")
|
||||
|
||||
// Set namespace in context for k8s storage layer
|
||||
ctx = k8srequest.WithNamespace(ctx, namespace)
|
||||
|
||||
// Create via storage (dual-write mode decides legacy, unified, or both)
|
||||
result, err := creater.Create(ctx, snapshot, nil, &metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract key and deleteKey from result
|
||||
accessor, err := utils.MetaAccessor(result)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, fmt.Errorf("failed to access result metadata: %w", err), w)
|
||||
return
|
||||
}
|
||||
|
||||
deleteKey, err := util.GetRandomString(32)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, fmt.Errorf("failed to generate delete key: %w", err), w)
|
||||
}
|
||||
|
||||
key := accessor.GetName()
|
||||
//deleteKey := ""
|
||||
//if annotations := accessor.GetAnnotations(); annotations != nil {
|
||||
// deleteKey = annotations["grafana.app/delete-key"]
|
||||
//}
|
||||
|
||||
// Build response
|
||||
response := dashv0.DashboardCreateResponse{
|
||||
Key: key,
|
||||
DeleteKey: deleteKey,
|
||||
URL: setting.ToAbsUrl("dashboard/snapshot/" + key),
|
||||
DeleteURL: setting.ToAbsUrl("api/snapshots-delete/" + deleteKey),
|
||||
}
|
||||
|
||||
wrap.JSON(http.StatusOK, response)
|
||||
// Use the existing snapshot service
|
||||
dashboardsnapshots.CreateDashboardSnapshot(wrap, options, cmd, service)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -21,10 +20,7 @@ var (
|
||||
_ rest.SingularNameProvider = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.Getter = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.Lister = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.Creater = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.Updater = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.GracefulDeleter = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.CollectionDeleter = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.Storage = (*SnapshotLegacyStore)(nil)
|
||||
)
|
||||
|
||||
@@ -133,51 +129,3 @@ func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *met
|
||||
}
|
||||
return nil, s.ResourceInfo.NewNotFound(name)
|
||||
}
|
||||
|
||||
// Create implements rest.Creater
|
||||
func (s *SnapshotLegacyStore) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
snap, ok := obj.(*dashV0.Snapshot)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected Snapshot object, got %T", obj)
|
||||
}
|
||||
|
||||
// Run validation if provided
|
||||
if createValidation != nil {
|
||||
if err := createValidation(ctx, obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Get user identity from context
|
||||
requester, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get requester: %w", err)
|
||||
}
|
||||
|
||||
userID, err := requester.GetInternalID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user ID: %w", err)
|
||||
}
|
||||
|
||||
// Convert K8s resource to service command
|
||||
cmd := convertK8sResourceToCreateCommand(snap, requester.GetOrgID(), userID)
|
||||
|
||||
// Create the snapshot via service
|
||||
result, err := s.Service.CreateDashboardSnapshot(ctx, cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert result back to K8s resource
|
||||
return convertSnapshotToK8sResource(result, s.Namespacer), nil
|
||||
}
|
||||
|
||||
// Update implements rest.Updater - snapshots are immutable, so this returns an error
|
||||
func (s *SnapshotLegacyStore) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
|
||||
return nil, false, fmt.Errorf("snapshots are immutable and cannot be updated")
|
||||
}
|
||||
|
||||
// DeleteCollection implements rest.CollectionDeleter
|
||||
func (s *SnapshotLegacyStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
|
||||
return nil, fmt.Errorf("delete collection is not supported for snapshots")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -11,19 +10,22 @@ import (
|
||||
|
||||
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
)
|
||||
|
||||
// Currently only works with v0alpha1
|
||||
type dashboardREST struct {
|
||||
getter rest.Getter
|
||||
Service dashboardsnapshots.Service
|
||||
}
|
||||
|
||||
func NewDashboardREST(
|
||||
getter rest.Getter,
|
||||
resourceInfo utils.ResourceInfo,
|
||||
service dashboardsnapshots.Service,
|
||||
) (rest.Storage, error) {
|
||||
return &dashboardREST{
|
||||
getter: getter,
|
||||
Service: service,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -56,30 +58,22 @@ func (r *dashboardREST) ProducesObject(verb string) interface{} {
|
||||
}
|
||||
|
||||
func (r *dashboardREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
ns, err := request.NamespaceInfoFrom(ctx, true)
|
||||
_, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the snapshot from unified storage
|
||||
obj, err := r.getter.Get(ctx, name, &metav1.GetOptions{})
|
||||
snap, err := r.Service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{Key: name})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snap, ok := obj.(*dashv0.Snapshot)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected Snapshot, got %T", obj)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
// TODO... support conversions (not required in v0)
|
||||
dash := &dashv0.Dashboard{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: ns.Value,
|
||||
Namespace: name,
|
||||
},
|
||||
Spec: v0alpha1.Unstructured{
|
||||
Object: snap.Spec.Dashboard,
|
||||
Object: snap.Dashboard.MustMap(),
|
||||
},
|
||||
}
|
||||
responder.Object(200, dash)
|
||||
|
||||
@@ -57,6 +57,12 @@ func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Objec
|
||||
}
|
||||
|
||||
func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
if s.dsConfigHandlerRequestsDuration != nil {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.List"), time.Since(start).Seconds())
|
||||
}()
|
||||
}
|
||||
return s.datasources.ListDataSources(ctx)
|
||||
}
|
||||
|
||||
@@ -64,7 +70,7 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge
|
||||
if s.dsConfigHandlerRequestsDuration != nil {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds())
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Get"), time.Since(start).Seconds())
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -76,7 +82,7 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
|
||||
if s.dsConfigHandlerRequestsDuration != nil {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Create"), time.Since(start).Seconds())
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -92,7 +98,7 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up
|
||||
if s.dsConfigHandlerRequestsDuration != nil {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Update"), time.Since(start).Seconds())
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -135,7 +141,7 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio
|
||||
if s.dsConfigHandlerRequestsDuration != nil {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Delete"), time.Since(start).Seconds())
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -145,6 +151,13 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio
|
||||
|
||||
// DeleteCollection implements rest.CollectionDeleter.
|
||||
func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
|
||||
if s.dsConfigHandlerRequestsDuration != nil {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.DeleteCollection"), time.Since(start).Seconds())
|
||||
}()
|
||||
}
|
||||
|
||||
dss, err := s.datasources.ListDataSources(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/sources"
|
||||
@@ -69,10 +70,10 @@ func RegisterAPIService(
|
||||
|
||||
dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "grafana",
|
||||
Name: "ds_config_handler_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by datasource configuration handlers",
|
||||
}, []string{"code_path", "handler"})
|
||||
regErr := reg.Register(dataSourceCRUDMetric)
|
||||
Name: "ds_config_handler_apis_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by new k8s style APIs datasource configuration handlers",
|
||||
}, []string{"handler"})
|
||||
regErr := metrics.ProvideRegisterer().Register(dataSourceCRUDMetric)
|
||||
if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) {
|
||||
return nil, regErr
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
|
||||
grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
)
|
||||
@@ -36,9 +37,13 @@ func ProvideAppInstaller(
|
||||
pluginStore pluginstore.Store,
|
||||
pluginAssetsService *pluginassets.Service,
|
||||
accessControlService accesscontrol.Service, accessClient authlib.AccessClient,
|
||||
features featuremgmt.FeatureToggles,
|
||||
) (*AppInstaller, error) {
|
||||
if err := registerAccessControlRoles(accessControlService); err != nil {
|
||||
return nil, fmt.Errorf("registering access control roles: %w", err)
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if features.IsEnabledGlobally(featuremgmt.FlagPluginStoreServiceLoading) {
|
||||
if err := registerAccessControlRoles(accessControlService); err != nil {
|
||||
return nil, fmt.Errorf("registering access control roles: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
localProvider := meta.NewLocalProvider(pluginStore, pluginAssetsService)
|
||||
|
||||
@@ -330,6 +330,7 @@ var wireBasicSet = wire.NewSet(
|
||||
dashsnapstore.ProvideStore,
|
||||
wire.Bind(new(dashboardsnapshots.Service), new(*dashsnapsvc.ServiceImpl)),
|
||||
dashsnapsvc.ProvideService,
|
||||
datasourceservice.ProvideDataSourceRetriever,
|
||||
datasourceservice.ProvideService,
|
||||
wire.Bind(new(datasources.DataSourceService), new(*datasourceservice.Service)),
|
||||
datasourceservice.ProvideLegacyDataSourceLookup,
|
||||
|
||||
Generated
+7
-5
File diff suppressed because one or more lines are too long
@@ -3,7 +3,6 @@ package authorizer
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
k8suser "k8s.io/apiserver/pkg/authentication/user"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
@@ -29,9 +28,9 @@ type GrafanaAuthorizer struct {
|
||||
// 4. We check authorizer that is configured speficially for an api.
|
||||
// 5. As a last fallback we check Role, this will only happen if an api have not configured
|
||||
// an authorizer or return authorizer.DecisionNoOpinion
|
||||
func NewGrafanaBuiltInSTAuthorizer(cfg *setting.Cfg) *GrafanaAuthorizer {
|
||||
func NewGrafanaBuiltInSTAuthorizer() *GrafanaAuthorizer {
|
||||
authorizers := []authorizer.Authorizer{
|
||||
newImpersonationAuthorizer(),
|
||||
NewImpersonationAuthorizer(),
|
||||
authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup),
|
||||
newNamespaceAuthorizer(),
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
var _ authorizer.Authorizer = (*impersonationAuthorizer)(nil)
|
||||
|
||||
func newImpersonationAuthorizer() *impersonationAuthorizer {
|
||||
func NewImpersonationAuthorizer() *impersonationAuthorizer {
|
||||
return &impersonationAuthorizer{}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,19 +76,7 @@ var PathRewriters = []filters.PathRewriter{
|
||||
|
||||
func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.Registerer) BuildHandlerChainFunc {
|
||||
return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler {
|
||||
requestHandler, err := GetCustomRoutesHandler(
|
||||
delegateHandler,
|
||||
c.LoopbackClientConfig,
|
||||
builders,
|
||||
reg,
|
||||
c.MergedResourceConfig,
|
||||
)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("could not build the request handler for specified API builders: %s", err.Error()))
|
||||
}
|
||||
|
||||
// Needs to run last in request chain to function as expected, hence we register it first.
|
||||
handler := filters.WithTracingHTTPLoggingAttributes(requestHandler)
|
||||
handler := filters.WithTracingHTTPLoggingAttributes(delegateHandler)
|
||||
|
||||
// filters.WithRequester needs to be after the K8s chain because it depends on the K8s user in context
|
||||
handler = filters.WithRequester(handler)
|
||||
|
||||
@@ -3,146 +3,306 @@ package builder
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/emicklei/go-restful/v3"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
serverstorage "k8s.io/apiserver/pkg/server/storage"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
klog "k8s.io/klog/v2"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
)
|
||||
|
||||
type requestHandler struct {
|
||||
router *mux.Router
|
||||
// convertHandlerToRouteFunction converts an http.HandlerFunc to a restful.RouteFunction
|
||||
// It extracts path parameters from restful.Request and populates them in the request context
|
||||
// so that mux.Vars can read them (for backward compatibility with handlers that use mux.Vars)
|
||||
func convertHandlerToRouteFunction(handler http.HandlerFunc) restful.RouteFunction {
|
||||
return func(req *restful.Request, resp *restful.Response) {
|
||||
// Extract path parameters from restful.Request and populate mux.Vars
|
||||
// This is needed for backward compatibility with handlers that use mux.Vars(r)
|
||||
vars := make(map[string]string)
|
||||
|
||||
// Get all path parameters from the restful.Request
|
||||
// The restful.Request has PathParameters() method that returns a map
|
||||
pathParams := req.PathParameters()
|
||||
for key, value := range pathParams {
|
||||
vars[key] = value
|
||||
}
|
||||
|
||||
// Set the vars in the request context using mux.SetURLVars
|
||||
// This makes mux.Vars(r) work correctly
|
||||
if len(vars) > 0 {
|
||||
req.Request = mux.SetURLVars(req.Request, vars)
|
||||
}
|
||||
|
||||
handler(resp.ResponseWriter, req.Request)
|
||||
}
|
||||
}
|
||||
|
||||
func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder, metricsRegistry prometheus.Registerer, apiResourceConfig *serverstorage.ResourceConfig) (http.Handler, error) {
|
||||
useful := false // only true if any routes exist anywhere
|
||||
router := mux.NewRouter()
|
||||
// AugmentWebServicesWithCustomRoutes adds custom routes from builders to existing WebServices
|
||||
// in the container.
|
||||
func AugmentWebServicesWithCustomRoutes(
|
||||
container *restful.Container,
|
||||
builders []APIGroupBuilder,
|
||||
metricsRegistry prometheus.Registerer,
|
||||
apiResourceConfig *serverstorage.ResourceConfig,
|
||||
) error {
|
||||
if container == nil {
|
||||
return fmt.Errorf("container cannot be nil")
|
||||
}
|
||||
|
||||
metrics := NewCustomRouteMetrics(metricsRegistry)
|
||||
|
||||
for _, builder := range builders {
|
||||
provider, ok := builder.(APIGroupRouteProvider)
|
||||
// Build a map of existing WebServices by root path
|
||||
existingWebServices := make(map[string]*restful.WebService)
|
||||
for _, ws := range container.RegisteredWebServices() {
|
||||
existingWebServices[ws.RootPath()] = ws
|
||||
}
|
||||
|
||||
for _, b := range builders {
|
||||
provider, ok := b.(APIGroupRouteProvider)
|
||||
if !ok || provider == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, gv := range GetGroupVersions(builder) {
|
||||
// filter out api groups that are disabled in APIEnablementOptions
|
||||
for _, gv := range GetGroupVersions(b) {
|
||||
// Filter out disabled API groups
|
||||
gvr := gv.WithResource("")
|
||||
if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) {
|
||||
klog.InfoS("Skipping custom route handler for disabled group version", "gv", gv.String())
|
||||
klog.InfoS("Skipping custom routes for disabled group version", "gv", gv.String())
|
||||
continue
|
||||
}
|
||||
|
||||
routes := provider.GetAPIRoutes(gv)
|
||||
if routes == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
prefix := "/apis/" + gv.String()
|
||||
|
||||
// Root handlers
|
||||
var sub *mux.Router
|
||||
for _, route := range routes.Root {
|
||||
if sub == nil {
|
||||
sub = router.PathPrefix(prefix).Subrouter()
|
||||
sub.MethodNotAllowedHandler = &methodNotAllowedHandler{}
|
||||
}
|
||||
|
||||
useful = true
|
||||
methods, err := methodsFromSpec(route.Path, route.Spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
instrumentedHandler := metrics.InstrumentHandler(
|
||||
gv.Group,
|
||||
gv.Version,
|
||||
route.Path, // Use path as resource identifier
|
||||
route.Handler,
|
||||
)
|
||||
|
||||
sub.HandleFunc("/"+route.Path, instrumentedHandler).
|
||||
Methods(methods...)
|
||||
// Find or create WebService for this group version
|
||||
rootPath := "/apis/" + gv.String()
|
||||
ws, exists := existingWebServices[rootPath]
|
||||
if !exists {
|
||||
// Create a new WebService if one doesn't exist
|
||||
ws = new(restful.WebService)
|
||||
ws.Path(rootPath)
|
||||
container.Add(ws)
|
||||
existingWebServices[rootPath] = ws
|
||||
}
|
||||
|
||||
// Namespace handlers
|
||||
sub = nil
|
||||
prefix += "/namespaces/{namespace}"
|
||||
for _, route := range routes.Namespace {
|
||||
if sub == nil {
|
||||
sub = router.PathPrefix(prefix).Subrouter()
|
||||
sub.MethodNotAllowedHandler = &methodNotAllowedHandler{}
|
||||
}
|
||||
|
||||
useful = true
|
||||
methods, err := methodsFromSpec(route.Path, route.Spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add root handlers using OpenAPI specs
|
||||
for _, route := range routes.Root {
|
||||
instrumentedHandler := metrics.InstrumentHandler(
|
||||
gv.Group,
|
||||
gv.Version,
|
||||
route.Path, // Use path as resource identifier
|
||||
route.Path,
|
||||
route.Handler,
|
||||
)
|
||||
routeFunction := convertHandlerToRouteFunction(instrumentedHandler)
|
||||
|
||||
sub.HandleFunc("/"+route.Path, instrumentedHandler).
|
||||
Methods(methods...)
|
||||
// Use OpenAPI spec to configure routes properly
|
||||
if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, false); err != nil {
|
||||
return fmt.Errorf("failed to add root route %s: %w", route.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add namespace handlers using OpenAPI specs
|
||||
for _, route := range routes.Namespace {
|
||||
instrumentedHandler := metrics.InstrumentHandler(
|
||||
gv.Group,
|
||||
gv.Version,
|
||||
route.Path,
|
||||
route.Handler,
|
||||
)
|
||||
routeFunction := convertHandlerToRouteFunction(instrumentedHandler)
|
||||
|
||||
// Use OpenAPI spec to configure routes properly
|
||||
if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, true); err != nil {
|
||||
return fmt.Errorf("failed to add namespace route %s: %w", route.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !useful {
|
||||
return delegateHandler, nil
|
||||
}
|
||||
|
||||
// Per Gorilla Mux issue here: https://github.com/gorilla/mux/issues/616#issuecomment-798807509
|
||||
// default handler must come last
|
||||
router.PathPrefix("/").Handler(delegateHandler)
|
||||
|
||||
return &requestHandler{
|
||||
router: router,
|
||||
}, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *requestHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
h.router.ServeHTTP(w, req)
|
||||
// addRouteFromSpec adds routes to a WebService using OpenAPI specs
|
||||
func addRouteFromSpec(ws *restful.WebService, routePath string, pathProps *spec3.PathProps, handler restful.RouteFunction, isNamespaced bool) error {
|
||||
if pathProps == nil {
|
||||
return fmt.Errorf("pathProps cannot be nil for route %s", routePath)
|
||||
}
|
||||
|
||||
// Build the full path (relative to WebService root)
|
||||
var fullPath string
|
||||
if isNamespaced {
|
||||
fullPath = "/namespaces/{namespace}/" + routePath
|
||||
} else {
|
||||
fullPath = "/" + routePath
|
||||
}
|
||||
|
||||
// Add routes for each HTTP method defined in the OpenAPI spec
|
||||
operations := map[string]*spec3.Operation{
|
||||
"GET": pathProps.Get,
|
||||
"POST": pathProps.Post,
|
||||
"PUT": pathProps.Put,
|
||||
"PATCH": pathProps.Patch,
|
||||
"DELETE": pathProps.Delete,
|
||||
}
|
||||
|
||||
for method, operation := range operations {
|
||||
if operation == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create route builder for this method
|
||||
var routeBuilder *restful.RouteBuilder
|
||||
switch method {
|
||||
case "GET":
|
||||
routeBuilder = ws.GET(fullPath)
|
||||
case "POST":
|
||||
routeBuilder = ws.POST(fullPath)
|
||||
case "PUT":
|
||||
routeBuilder = ws.PUT(fullPath)
|
||||
case "PATCH":
|
||||
routeBuilder = ws.PATCH(fullPath)
|
||||
case "DELETE":
|
||||
routeBuilder = ws.DELETE(fullPath)
|
||||
}
|
||||
|
||||
// Set operation ID from OpenAPI spec (with K8s verb prefix if needed)
|
||||
operationID := operation.OperationId
|
||||
if operationID == "" {
|
||||
// Generate from path if not specified
|
||||
operationID = generateOperationNameFromPath(routePath)
|
||||
}
|
||||
operationID = prefixRouteIDWithK8sVerbIfNotPresent(operationID, method)
|
||||
routeBuilder = routeBuilder.Operation(operationID)
|
||||
|
||||
// Add description from OpenAPI spec
|
||||
if operation.Description != "" {
|
||||
routeBuilder = routeBuilder.Doc(operation.Description)
|
||||
}
|
||||
|
||||
// Check if namespace parameter is already in the OpenAPI spec
|
||||
hasNamespaceParam := false
|
||||
if operation.Parameters != nil {
|
||||
for _, param := range operation.Parameters {
|
||||
if param.Name == "namespace" && param.In == "path" {
|
||||
hasNamespaceParam = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add namespace parameter for namespaced routes if not already in spec
|
||||
if isNamespaced && !hasNamespaceParam {
|
||||
routeBuilder = routeBuilder.Param(restful.PathParameter("namespace", "object name and auth scope, such as for teams and projects"))
|
||||
}
|
||||
|
||||
// Add parameters from OpenAPI spec
|
||||
if operation.Parameters != nil {
|
||||
for _, param := range operation.Parameters {
|
||||
switch param.In {
|
||||
case "path":
|
||||
routeBuilder = routeBuilder.Param(restful.PathParameter(param.Name, param.Description))
|
||||
case "query":
|
||||
routeBuilder = routeBuilder.Param(restful.QueryParameter(param.Name, param.Description))
|
||||
case "header":
|
||||
routeBuilder = routeBuilder.Param(restful.HeaderParameter(param.Name, param.Description))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Request/response schemas are already defined in the OpenAPI spec from builders
|
||||
// and will be added to the OpenAPI document via addBuilderRoutes in openapi.go.
|
||||
// We don't duplicate that information here since restful uses the route metadata
|
||||
// for OpenAPI generation, which is handled separately in this codebase.
|
||||
|
||||
// Register the route with handler
|
||||
ws.Route(routeBuilder.To(handler))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func methodsFromSpec(slug string, props *spec3.PathProps) ([]string, error) {
|
||||
if props == nil {
|
||||
return []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, nil
|
||||
func prefixRouteIDWithK8sVerbIfNotPresent(operationID string, method string) string {
|
||||
for _, verb := range allowedK8sVerbs {
|
||||
if len(operationID) > len(verb) && operationID[:len(verb)] == verb {
|
||||
return operationID
|
||||
}
|
||||
}
|
||||
|
||||
methods := make([]string, 0)
|
||||
if props.Get != nil {
|
||||
methods = append(methods, "GET")
|
||||
}
|
||||
if props.Post != nil {
|
||||
methods = append(methods, "POST")
|
||||
}
|
||||
if props.Put != nil {
|
||||
methods = append(methods, "PUT")
|
||||
}
|
||||
if props.Patch != nil {
|
||||
methods = append(methods, "PATCH")
|
||||
}
|
||||
if props.Delete != nil {
|
||||
methods = append(methods, "DELETE")
|
||||
}
|
||||
|
||||
if len(methods) == 0 {
|
||||
return nil, fmt.Errorf("invalid OpenAPI Spec for slug=%s without any methods in PathProps", slug)
|
||||
}
|
||||
|
||||
return methods, nil
|
||||
return fmt.Sprintf("%s%s", httpMethodToK8sVerb[strings.ToUpper(method)], operationID)
|
||||
}
|
||||
|
||||
type methodNotAllowedHandler struct{}
|
||||
|
||||
func (h *methodNotAllowedHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
w.WriteHeader(405) // method not allowed
|
||||
var allowedK8sVerbs = []string{
|
||||
"get", "log", "read", "replace", "patch", "delete", "deletecollection", "watch", "connect", "proxy", "list", "create", "patch",
|
||||
}
|
||||
|
||||
var httpMethodToK8sVerb = map[string]string{
|
||||
http.MethodGet: "get",
|
||||
http.MethodPost: "create",
|
||||
http.MethodPut: "replace",
|
||||
http.MethodPatch: "patch",
|
||||
http.MethodDelete: "delete",
|
||||
http.MethodConnect: "connect",
|
||||
http.MethodOptions: "connect", // No real equivalent to options and head
|
||||
http.MethodHead: "connect",
|
||||
}
|
||||
|
||||
// generateOperationNameFromPath creates an operation name from a route path.
|
||||
// The operation name is used by the OpenAPI generator and should be descriptive.
|
||||
// It uses meaningful path segments to create readable yet unique operation names.
|
||||
// Examples:
|
||||
// - "/search" -> "Search"
|
||||
// - "/snapshots/create" -> "SnapshotsCreate"
|
||||
// - "ofrep/v1/evaluate/flags" -> "OfrepEvaluateFlags"
|
||||
// - "ofrep/v1/evaluate/flags/{flagKey}" -> "OfrepEvaluateFlagsFlagKey"
|
||||
func generateOperationNameFromPath(routePath string) string {
|
||||
// Remove leading slash and split by path segments
|
||||
parts := strings.Split(strings.TrimPrefix(routePath, "/"), "/")
|
||||
|
||||
// Filter to keep meaningful segments and path parameters
|
||||
var nameParts []string
|
||||
skipPrefixes := map[string]bool{
|
||||
"namespaces": true,
|
||||
"apis": true,
|
||||
}
|
||||
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract parameter name from {paramName} format
|
||||
if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") {
|
||||
paramName := part[1 : len(part)-1]
|
||||
// Skip generic parameters like {namespace}, but keep specific ones like {flagKey}
|
||||
if paramName != "namespace" && paramName != "name" {
|
||||
nameParts = append(nameParts, strings.ToUpper(paramName[:1])+paramName[1:])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip common prefixes
|
||||
if skipPrefixes[strings.ToLower(part)] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip version segments like v1, v0alpha1, v2beta1, etc.
|
||||
if strings.HasPrefix(strings.ToLower(part), "v") &&
|
||||
(len(part) <= 3 || strings.Contains(strings.ToLower(part), "alpha") || strings.Contains(strings.ToLower(part), "beta")) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Capitalize first letter and add to parts
|
||||
if len(part) > 0 {
|
||||
nameParts = append(nameParts, strings.ToUpper(part[:1])+part[1:])
|
||||
}
|
||||
}
|
||||
|
||||
if len(nameParts) == 0 {
|
||||
return "Route"
|
||||
}
|
||||
|
||||
return strings.Join(nameParts, "")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/options"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
@@ -41,15 +40,6 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o
|
||||
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
|
||||
|
||||
runtimeConfig := apiserverCfg.Key("runtime_config").String()
|
||||
runtimeConfigSplit := strings.Split(runtimeConfig, ",")
|
||||
|
||||
// TODO: temporary fix to allow disabling local features service and still being able to use its authz handler
|
||||
if !cfg.OpenFeature.APIEnabled {
|
||||
runtimeConfigSplit = append(runtimeConfigSplit, "features.grafana.app/v0alpha1=false")
|
||||
}
|
||||
|
||||
runtimeConfig = strings.Join(runtimeConfigSplit, ",")
|
||||
|
||||
if runtimeConfig != "" {
|
||||
if err := o.APIEnablementOptions.RuntimeConfig.Set(runtimeConfig); err != nil {
|
||||
return fmt.Errorf("failed to set runtime config: %w", err)
|
||||
|
||||
@@ -155,7 +155,7 @@ func ProvideService(
|
||||
features: features,
|
||||
rr: rr,
|
||||
builders: []builder.APIGroupBuilder{},
|
||||
authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(cfg),
|
||||
authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(),
|
||||
tracing: tracing,
|
||||
db: db, // For Unified storage
|
||||
metrics: reg,
|
||||
@@ -443,6 +443,19 @@ func (s *service) start(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Augment existing WebServices with custom routes from builders
|
||||
// This directly adds routes to existing WebServices using the OpenAPI specs from builders
|
||||
if server.Handler != nil && server.Handler.GoRestfulContainer != nil {
|
||||
if err := builder.AugmentWebServicesWithCustomRoutes(
|
||||
server.Handler.GoRestfulContainer,
|
||||
builders,
|
||||
s.metrics,
|
||||
serverConfig.MergedResourceConfig,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to augment web services with custom routes: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stash the options for later use
|
||||
s.options = o
|
||||
|
||||
|
||||
@@ -15,9 +15,7 @@ import (
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1beta1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
@@ -63,7 +61,6 @@ type CleanUpService struct {
|
||||
orgService org.Service
|
||||
teamService team.Service
|
||||
dataSourceService datasources.DataSourceService
|
||||
dynamicClientFactory func(*rest.Config) (dynamic.Interface, error)
|
||||
}
|
||||
|
||||
func ProvideService(cfg *setting.Cfg, Features featuremgmt.FeatureToggles, serverLockService *serverlock.ServerLockService,
|
||||
@@ -89,9 +86,6 @@ func ProvideService(cfg *setting.Cfg, Features featuremgmt.FeatureToggles, serve
|
||||
orgService: orgService,
|
||||
teamService: teamService,
|
||||
dataSourceService: dataSourceService,
|
||||
dynamicClientFactory: func(c *rest.Config) (dynamic.Interface, error) {
|
||||
return dynamic.NewForConfig(c)
|
||||
},
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -236,93 +230,14 @@ func (srv *CleanUpService) shouldCleanupTempFile(filemtime time.Time, now time.T
|
||||
|
||||
func (srv *CleanUpService) deleteExpiredSnapshots(ctx context.Context) {
|
||||
logger := srv.log.FromContext(ctx)
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if srv.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesSnapshots) {
|
||||
srv.deleteKubernetesExpiredSnapshots(ctx)
|
||||
cmd := dashboardsnapshots.DeleteExpiredSnapshotsCommand{}
|
||||
if err := srv.dashboardSnapshotService.DeleteExpiredSnapshots(ctx, &cmd); err != nil {
|
||||
logger.Error("Failed to delete expired snapshots", "error", err.Error())
|
||||
} else {
|
||||
cmd := dashboardsnapshots.DeleteExpiredSnapshotsCommand{}
|
||||
if err := srv.dashboardSnapshotService.DeleteExpiredSnapshots(ctx, &cmd); err != nil {
|
||||
logger.Error("Failed to delete expired snapshots", "error", err.Error())
|
||||
} else {
|
||||
logger.Debug("Deleted expired snapshots", "rows affected", cmd.DeletedRows)
|
||||
}
|
||||
logger.Debug("Deleted expired snapshots", "rows affected", cmd.DeletedRows)
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *CleanUpService) deleteKubernetesExpiredSnapshots(ctx context.Context) {
|
||||
logger := srv.log.FromContext(ctx)
|
||||
logger.Debug("Starting deleting expired Kubernetes snapshots")
|
||||
|
||||
// Create the dynamic client for Kubernetes API
|
||||
restConfig, err := srv.clientConfigProvider.GetRestConfig(ctx)
|
||||
if err != nil {
|
||||
logger.Error("Failed to get REST config for Kubernetes client", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
client, err := srv.dynamicClientFactory(restConfig)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Kubernetes client", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Set up the GroupVersionResource for snapshots
|
||||
gvr := v0alpha1.SnapshotKind().GroupVersionResource()
|
||||
|
||||
// Expiration time is now
|
||||
expirationTime := time.Now()
|
||||
expirationTimestamp := expirationTime.UnixMilli()
|
||||
deletedCount := 0
|
||||
|
||||
// List and delete expired snapshots across all namespaces
|
||||
orgs, err := srv.orgService.Search(ctx, &org.SearchOrgsQuery{})
|
||||
if err != nil {
|
||||
logger.Error("Failed to list organizations", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
for _, o := range orgs {
|
||||
ctx, _ := identity.WithServiceIdentity(ctx, o.ID)
|
||||
namespaceMapper := request.GetNamespaceMapper(srv.Cfg)
|
||||
snapshots, err := client.Resource(gvr).Namespace(namespaceMapper(o.ID)).List(ctx, v1.ListOptions{})
|
||||
if err != nil {
|
||||
logger.Error("Failed to list snapshots", "error", err.Error())
|
||||
return
|
||||
}
|
||||
// Check each snapshot for expiration
|
||||
for _, item := range snapshots.Items {
|
||||
// Convert unstructured object to Snapshot struct
|
||||
var snapshot v0alpha1.Snapshot
|
||||
err := runtime.DefaultUnstructuredConverter.FromUnstructured(item.Object, &snapshot)
|
||||
if err != nil {
|
||||
logger.Error("Failed to convert unstructured object to snapshot", "name", item.GetName(), "namespace", item.GetNamespace(), "error", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// Only delete expired snapshots
|
||||
if snapshot.Spec.Expires != nil && *snapshot.Spec.Expires < expirationTimestamp {
|
||||
namespace := snapshot.Namespace
|
||||
name := snapshot.Name
|
||||
|
||||
err := client.Resource(gvr).Namespace(namespace).Delete(ctx, name, v1.DeleteOptions{})
|
||||
if err != nil {
|
||||
// Check if it's a "not found" error, which is expected if the resource was already deleted
|
||||
if k8serrors.IsNotFound(err) {
|
||||
logger.Debug("Snapshot already deleted", "name", name, "namespace", namespace)
|
||||
} else {
|
||||
logger.Error("Failed to delete expired snapshot", "name", name, "namespace", namespace, "error", err.Error())
|
||||
}
|
||||
} else {
|
||||
deletedCount++
|
||||
logger.Debug("Successfully deleted expired snapshot", "name", name, "namespace", namespace, "creationTime", snapshot.CreationTimestamp.Unix(), "expirationTime", expirationTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("Deleted expired Kubernetes snapshots", "count", deletedCount)
|
||||
}
|
||||
|
||||
func (srv *CleanUpService) deleteExpiredDashboardVersions(ctx context.Context) {
|
||||
logger := srv.log.FromContext(ctx)
|
||||
cmd := dashver.DeleteExpiredVersionsCommand{}
|
||||
@@ -403,7 +318,7 @@ func (srv *CleanUpService) deleteStaleKubernetesShortURLs(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := srv.dynamicClientFactory(restConfig)
|
||||
client, err := dynamic.NewForConfig(restConfig)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Kubernetes client", "error", err.Error())
|
||||
return
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
package cleanup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/org/orgtest"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
@@ -55,223 +36,3 @@ func TestCleanUpTmpFiles(t *testing.T) {
|
||||
require.False(t, service.shouldCleanupTempFile(weekAgo, now))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteExpiredSnapshots_LegacyMode(t *testing.T) {
|
||||
t.Run("calls DeleteExpiredSnapshots on success", func(t *testing.T) {
|
||||
mockSnapService := dashboardsnapshots.NewMockService(t)
|
||||
mockSnapService.On("DeleteExpiredSnapshots", mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
service := &CleanUpService{
|
||||
log: log.New("cleanup"),
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
dashboardSnapshotService: mockSnapService,
|
||||
}
|
||||
|
||||
service.deleteExpiredSnapshots(context.Background())
|
||||
|
||||
mockSnapService.AssertCalled(t, "DeleteExpiredSnapshots", mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("handles error gracefully", func(t *testing.T) {
|
||||
mockSnapService := dashboardsnapshots.NewMockService(t)
|
||||
mockSnapService.On("DeleteExpiredSnapshots", mock.Anything, mock.Anything).Return(errors.New("db error"))
|
||||
|
||||
service := &CleanUpService{
|
||||
log: log.New("cleanup"),
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
dashboardSnapshotService: mockSnapService,
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
service.deleteExpiredSnapshots(context.Background())
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteExpiredSnapshots_KubernetesMode(t *testing.T) {
|
||||
t.Run("deletes expired snapshots across multiple orgs", func(t *testing.T) {
|
||||
// Create expired snapshots - one per org
|
||||
expiredTime := time.Now().Add(-time.Hour).UnixMilli()
|
||||
expiredSnapshot1 := createUnstructuredSnapshot("expired-snap-1", "org-1", expiredTime)
|
||||
expiredSnapshot2 := createUnstructuredSnapshot("expired-snap-2", "org-2", expiredTime)
|
||||
|
||||
// Track which namespaces were queried
|
||||
namespacesQueried := make(map[string]bool)
|
||||
|
||||
mockResource := new(mockResourceInterface)
|
||||
mockResource.On("Namespace", mock.Anything).Run(func(args mock.Arguments) {
|
||||
ns := args.Get(0).(string)
|
||||
namespacesQueried[ns] = true
|
||||
}).Return(mockResource)
|
||||
mockResource.On("List", mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{
|
||||
Items: []unstructured.Unstructured{*expiredSnapshot1, *expiredSnapshot2},
|
||||
}, nil)
|
||||
mockResource.On("Delete", mock.Anything, "expired-snap-1", mock.Anything, mock.Anything).Return(nil)
|
||||
mockResource.On("Delete", mock.Anything, "expired-snap-2", mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
mockDynClient := new(mockDynamicClient)
|
||||
mockDynClient.On("Resource", mock.Anything).Return(mockResource)
|
||||
|
||||
service := createK8sCleanupService(t, mockDynClient)
|
||||
service.deleteExpiredSnapshots(context.Background())
|
||||
|
||||
// Verify multiple namespaces were queried (one per org)
|
||||
require.GreaterOrEqual(t, len(namespacesQueried), 2, "expected at least 2 namespaces to be queried")
|
||||
// Verify both snapshots were deleted
|
||||
mockResource.AssertCalled(t, "Delete", mock.Anything, "expired-snap-1", mock.Anything, mock.Anything)
|
||||
mockResource.AssertCalled(t, "Delete", mock.Anything, "expired-snap-2", mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("skips non-expired snapshots", func(t *testing.T) {
|
||||
// Setup with future timestamp
|
||||
futureTime := time.Now().Add(time.Hour).UnixMilli()
|
||||
futureSnapshot := createUnstructuredSnapshot("future-snap", "org-1", futureTime)
|
||||
|
||||
mockResource := new(mockResourceInterface)
|
||||
mockResource.On("Namespace", mock.Anything).Return(mockResource)
|
||||
mockResource.On("List", mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{
|
||||
Items: []unstructured.Unstructured{*futureSnapshot},
|
||||
}, nil)
|
||||
|
||||
mockDynClient := new(mockDynamicClient)
|
||||
mockDynClient.On("Resource", mock.Anything).Return(mockResource)
|
||||
|
||||
service := createK8sCleanupService(t, mockDynClient)
|
||||
service.deleteExpiredSnapshots(context.Background())
|
||||
|
||||
mockResource.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("handles REST config error", func(t *testing.T) {
|
||||
service := &CleanUpService{
|
||||
log: log.New("cleanup"),
|
||||
Cfg: &setting.Cfg{},
|
||||
Features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesSnapshots),
|
||||
clientConfigProvider: apiserver.WithoutRestConfig,
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
service.deleteExpiredSnapshots(context.Background())
|
||||
})
|
||||
|
||||
t.Run("handles not found error gracefully", func(t *testing.T) {
|
||||
expiredTime := time.Now().Add(-time.Hour).UnixMilli()
|
||||
expiredSnapshot := createUnstructuredSnapshot("expired-snap", "org-1", expiredTime)
|
||||
|
||||
notFoundErr := k8serrors.NewNotFound(schema.GroupResource{}, "expired-snap")
|
||||
|
||||
mockResource := new(mockResourceInterface)
|
||||
mockResource.On("Namespace", mock.Anything).Return(mockResource)
|
||||
mockResource.On("List", mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{
|
||||
Items: []unstructured.Unstructured{*expiredSnapshot},
|
||||
}, nil)
|
||||
mockResource.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(notFoundErr)
|
||||
|
||||
mockDynClient := new(mockDynamicClient)
|
||||
mockDynClient.On("Resource", mock.Anything).Return(mockResource)
|
||||
|
||||
service := createK8sCleanupService(t, mockDynClient)
|
||||
|
||||
// Should not panic - not found is expected
|
||||
service.deleteExpiredSnapshots(context.Background())
|
||||
mockResource.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to create unstructured snapshots for testing
|
||||
func createUnstructuredSnapshot(name, namespace string, expiresMillis int64) *unstructured.Unstructured {
|
||||
snapshot := &v0alpha1.Snapshot{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
},
|
||||
Spec: v0alpha1.SnapshotSpec{
|
||||
Expires: &expiresMillis,
|
||||
},
|
||||
}
|
||||
obj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(snapshot)
|
||||
return &unstructured.Unstructured{Object: obj}
|
||||
}
|
||||
|
||||
// Helper to create CleanUpService configured for Kubernetes mode with standard two-org setup
|
||||
func createK8sCleanupService(t *testing.T, mockDynClient *mockDynamicClient) *CleanUpService {
|
||||
mockOrgSvc := orgtest.NewMockService(t)
|
||||
mockOrgSvc.On("Search", mock.Anything, mock.Anything).Return([]*org.OrgDTO{
|
||||
{ID: 1, Name: "org1"},
|
||||
{ID: 2, Name: "org2"},
|
||||
}, nil)
|
||||
|
||||
return &CleanUpService{
|
||||
log: log.New("cleanup"),
|
||||
Cfg: &setting.Cfg{},
|
||||
Features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesSnapshots),
|
||||
clientConfigProvider: apiserver.RestConfigProviderFunc(func(ctx context.Context) (*rest.Config, error) {
|
||||
return &rest.Config{}, nil
|
||||
}),
|
||||
orgService: mockOrgSvc,
|
||||
dynamicClientFactory: func(cfg *rest.Config) (dynamic.Interface, error) {
|
||||
return mockDynClient, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// mockDynamicClient is a minimal mock for dynamic.Interface
|
||||
type mockDynamicClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockDynamicClient) Resource(resource schema.GroupVersionResource) dynamic.NamespaceableResourceInterface {
|
||||
args := m.Called(resource)
|
||||
return args.Get(0).(dynamic.NamespaceableResourceInterface)
|
||||
}
|
||||
|
||||
// mockResourceInterface is a minimal mock for dynamic.ResourceInterface
|
||||
type mockResourceInterface struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockResourceInterface) Namespace(ns string) dynamic.ResourceInterface {
|
||||
args := m.Called(ns)
|
||||
return args.Get(0).(dynamic.ResourceInterface)
|
||||
}
|
||||
|
||||
func (m *mockResourceInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) {
|
||||
args := m.Called(ctx, opts)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*unstructured.UnstructuredList), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockResourceInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions, subresources ...string) error {
|
||||
args := m.Called(ctx, name, opts, subresources)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// Unused methods - panic if called unexpectedly
|
||||
func (m *mockResourceInterface) Create(ctx context.Context, obj *unstructured.Unstructured, opts metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) Update(ctx context.Context, obj *unstructured.Unstructured, opts metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, opts metav1.UpdateOptions) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) Get(ctx context.Context, name string, opts metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) Apply(ctx context.Context, name string, obj *unstructured.Unstructured, opts metav1.ApplyOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) ApplyStatus(ctx context.Context, name string, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
@@ -37,15 +37,10 @@ var client = &http.Client{
|
||||
}
|
||||
|
||||
func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSharingOptions, cmd CreateDashboardSnapshotCommand, svc Service) {
|
||||
// perform all validations in the beginning
|
||||
if !cfg.SnapshotsEnabled {
|
||||
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
|
||||
return
|
||||
}
|
||||
if cmd.External && !cfg.ExternalEnabled {
|
||||
c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
|
||||
return
|
||||
}
|
||||
|
||||
uid := cmd.Dashboard.GetNestedString("uid")
|
||||
user, err := identity.GetRequester(c.Req.Context())
|
||||
@@ -72,17 +67,17 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh
|
||||
cmd.ExternalURL = ""
|
||||
cmd.OrgID = user.GetOrgID()
|
||||
cmd.UserID, _ = identity.UserIdentifier(user.GetID())
|
||||
originalDashboardURL, err := CreateOriginalDashboardURL(&cmd)
|
||||
originalDashboardURL, err := createOriginalDashboardURL(&cmd)
|
||||
if err != nil {
|
||||
c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err)
|
||||
return
|
||||
}
|
||||
|
||||
if cmd.External {
|
||||
//if !cfg.ExternalEnabled {
|
||||
// c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
|
||||
// return
|
||||
//}
|
||||
if !cfg.ExternalEnabled {
|
||||
c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := createExternalDashboardSnapshot(cmd, cfg.ExternalSnapshotURL)
|
||||
if err != nil {
|
||||
@@ -208,7 +203,7 @@ func createExternalDashboardSnapshot(cmd CreateDashboardSnapshotCommand, externa
|
||||
return &createSnapshotResponse, nil
|
||||
}
|
||||
|
||||
func CreateOriginalDashboardURL(cmd *CreateDashboardSnapshotCommand) (string, error) {
|
||||
func createOriginalDashboardURL(cmd *CreateDashboardSnapshotCommand) (string, error) {
|
||||
dashUID := cmd.Dashboard.GetNestedString("uid")
|
||||
if ok := util.IsValidShortUID(dashUID); !ok {
|
||||
return "", fmt.Errorf("invalid dashboard UID")
|
||||
|
||||
@@ -51,6 +51,7 @@ type Service struct {
|
||||
pluginStore pluginstore.Store
|
||||
pluginClient plugins.Client
|
||||
basePluginContextProvider plugincontext.BasePluginContextProvider
|
||||
retriever DataSourceRetriever
|
||||
|
||||
ptc proxyTransportCache
|
||||
}
|
||||
@@ -70,6 +71,7 @@ func ProvideService(
|
||||
features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, datasourcePermissionsService accesscontrol.DatasourcePermissionsService,
|
||||
quotaService quota.Service, pluginStore pluginstore.Store, pluginClient plugins.Client,
|
||||
basePluginContextProvider plugincontext.BasePluginContextProvider,
|
||||
retriever DataSourceRetriever,
|
||||
) (*Service, error) {
|
||||
dslogger := log.New("datasources")
|
||||
store := &SqlStore{db: db, logger: dslogger, features: features}
|
||||
@@ -89,6 +91,7 @@ func ProvideService(
|
||||
pluginStore: pluginStore,
|
||||
pluginClient: pluginClient,
|
||||
basePluginContextProvider: basePluginContextProvider,
|
||||
retriever: retriever,
|
||||
}
|
||||
|
||||
ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store))
|
||||
@@ -175,11 +178,11 @@ func NewIDScopeResolver(db DataSourceRetriever) (string, accesscontrol.ScopeAttr
|
||||
}
|
||||
|
||||
func (s *Service) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) {
|
||||
return s.SQLStore.GetDataSource(ctx, query)
|
||||
return s.retriever.GetDataSource(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) GetDataSourceInNamespace(ctx context.Context, namespace, name, group string) (*datasources.DataSource, error) {
|
||||
return s.SQLStore.GetDataSourceInNamespace(ctx, namespace, name, group)
|
||||
return s.retriever.GetDataSourceInNamespace(ctx, namespace, name, group)
|
||||
}
|
||||
|
||||
func (s *Service) GetDataSources(ctx context.Context, query *datasources.GetDataSourcesQuery) ([]*datasources.DataSource, error) {
|
||||
|
||||
@@ -832,8 +832,9 @@ func TestIntegrationService_DeleteDataSource(t *testing.T) {
|
||||
quotaService := quotatest.New(false, nil)
|
||||
permissionSvc := acmock.NewMockedPermissionsService()
|
||||
permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
|
||||
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := &datasources.DeleteDataSourceCommand{
|
||||
@@ -857,7 +858,9 @@ func TestIntegrationService_DeleteDataSource(t *testing.T) {
|
||||
permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||
cfg := &setting.Cfg{}
|
||||
enableRBACManagedPermissions(t, cfg)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
// First add the datasource
|
||||
@@ -1124,7 +1127,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider)
|
||||
@@ -1161,7 +1166,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
ds := datasources.DataSource{
|
||||
@@ -1212,7 +1219,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
ds := datasources.DataSource{
|
||||
@@ -1260,7 +1269,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
ds := datasources.DataSource{
|
||||
@@ -1316,7 +1327,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
ds := datasources.DataSource{
|
||||
@@ -1351,7 +1364,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
ds := datasources.DataSource{
|
||||
@@ -1420,7 +1435,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
ds := datasources.DataSource{
|
||||
@@ -1499,7 +1516,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
ds := datasources.DataSource{
|
||||
@@ -1522,7 +1541,9 @@ func TestIntegrationService_getProxySettings(t *testing.T) {
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Should default to disabled", func(t *testing.T) {
|
||||
@@ -1620,7 +1641,9 @@ func TestIntegrationService_getTimeout(t *testing.T) {
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -1645,7 +1668,9 @@ func TestIntegrationService_GetDecryptedValues(t *testing.T) {
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
jsonData := map[string]string{
|
||||
@@ -1673,7 +1698,9 @@ func TestIntegrationService_GetDecryptedValues(t *testing.T) {
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
jsonData := map[string]string{
|
||||
@@ -1699,7 +1726,9 @@ func TestIntegrationDataSource_CustomHeaders(t *testing.T) {
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
dsService.cfg = setting.NewCfg()
|
||||
@@ -1788,7 +1817,9 @@ func initDSService(t *testing.T) *Service {
|
||||
quotaService := quotatest.New(false, nil)
|
||||
mockPermission := acmock.NewMockedPermissionsService()
|
||||
mockPermission.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{
|
||||
features := featuremgmt.WithFeatures()
|
||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{
|
||||
PluginList: []pluginstore.Plugin{{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "test",
|
||||
@@ -1808,7 +1839,7 @@ func initDSService(t *testing.T) *Service {
|
||||
ObjectBytes: req.ObjectBytes,
|
||||
}, nil
|
||||
},
|
||||
}, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
||||
}, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
||||
require.NoError(t, err)
|
||||
|
||||
return dsService
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
)
|
||||
|
||||
// DataSourceRetrieverImpl implements DataSourceRetriever by delegating to a Store.
|
||||
type DataSourceRetrieverImpl struct {
|
||||
store Store
|
||||
}
|
||||
|
||||
var _ DataSourceRetriever = (*DataSourceRetrieverImpl)(nil)
|
||||
|
||||
// ProvideDataSourceRetriever creates a DataSourceRetriever for wire injection.
|
||||
func ProvideDataSourceRetriever(db db.DB, features featuremgmt.FeatureToggles) DataSourceRetriever {
|
||||
dslogger := log.New("datasources-retriever")
|
||||
store := &SqlStore{db: db, logger: dslogger, features: features}
|
||||
return &DataSourceRetrieverImpl{store: store}
|
||||
}
|
||||
|
||||
// GetDataSource gets a datasource.
|
||||
func (r *DataSourceRetrieverImpl) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) {
|
||||
return r.store.GetDataSource(ctx, query)
|
||||
}
|
||||
|
||||
// GetDataSourceInNamespace gets a datasource by namespace, name (datasource uid), and group (datasource type).
|
||||
func (r *DataSourceRetrieverImpl) GetDataSourceInNamespace(ctx context.Context, namespace, name, group string) (*datasources.DataSource, error) {
|
||||
return r.store.GetDataSourceInNamespace(ctx, namespace, name, group)
|
||||
}
|
||||
@@ -650,13 +650,6 @@ var (
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaDatavizSquad,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesFeatureToggles",
|
||||
Description: "Use the kubernetes API for feature toggle management in the frontend",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaOperatorExperienceSquad,
|
||||
},
|
||||
{
|
||||
Name: "cloudRBACRoles",
|
||||
Description: "Enabled grafana cloud specific RBAC roles",
|
||||
@@ -2090,6 +2083,13 @@ var (
|
||||
FrontendOnly: false,
|
||||
Owner: grafanaOperatorExperienceSquad,
|
||||
},
|
||||
{
|
||||
Name: "profilesExemplars",
|
||||
Description: "Enables profiles exemplars support in profiles drilldown",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaObservabilityTracesAndProfilingSquad,
|
||||
FrontendOnly: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -90,7 +90,6 @@ pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
|
||||
timeComparison,experimental,@grafana/dataviz-squad,false,false,true
|
||||
tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true
|
||||
kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true
|
||||
cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false
|
||||
alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false
|
||||
jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false
|
||||
@@ -283,3 +282,4 @@ useMTPlugins,experimental,@grafana/plugins-platform-backend,false,false,true
|
||||
multiPropsVariables,experimental,@grafana/dashboards-squad,false,false,true
|
||||
smoothingTransformation,experimental,@grafana/datapro,false,false,true
|
||||
secretsManagementAppPlatformAwsKeeper,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
profilesExemplars,experimental,@grafana/observability-traces-and-profiling,false,false,false
|
||||
|
||||
|
Generated
+4
@@ -785,4 +785,8 @@ const (
|
||||
// FlagSecretsManagementAppPlatformAwsKeeper
|
||||
// Enables the creation of keepers that manage secrets stored on AWS secrets manager
|
||||
FlagSecretsManagementAppPlatformAwsKeeper = "secretsManagementAppPlatformAwsKeeper"
|
||||
|
||||
// FlagProfilesExemplars
|
||||
// Enables profiles exemplars support in profiles drilldown
|
||||
FlagProfilesExemplars = "profilesExemplars"
|
||||
)
|
||||
|
||||
+14
-1
@@ -2044,7 +2044,8 @@
|
||||
"metadata": {
|
||||
"name": "kubernetesFeatureToggles",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2024-01-18T05:32:44Z"
|
||||
"creationTimestamp": "2024-01-18T05:32:44Z",
|
||||
"deletionTimestamp": "2026-01-07T12:02:51Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Use the kubernetes API for feature toggle management in the frontend",
|
||||
@@ -2866,6 +2867,18 @@
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "profilesExemplars",
|
||||
"resourceVersion": "1767777507980",
|
||||
"creationTimestamp": "2026-01-07T09:18:27Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables profiles exemplars support in profiles drilldown",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/observability-traces-and-profiling"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "prometheusAzureOverrideAudience",
|
||||
|
||||
@@ -542,9 +542,10 @@ func setupEnv(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, b bus.Bus, quotaSe
|
||||
dashService.RegisterDashboardPermissions(acmock.NewMockedPermissionsService())
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
dsRetriever := dsservice.ProvideDataSourceRetriever(sqlStore, featuremgmt.WithFeatures())
|
||||
_, err = dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(),
|
||||
quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, plugincontext.
|
||||
ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
||||
ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
||||
require.NoError(t, err)
|
||||
m := metrics.NewNGAlert(prometheus.NewRegistry())
|
||||
|
||||
|
||||
@@ -37,9 +37,10 @@ func SetupTestDataSourceSecretMigrationService(t *testing.T, sqlStore db.DB, kvS
|
||||
features := featuremgmt.WithFeatures()
|
||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
quotaService := quotatest.New(false, nil)
|
||||
dsRetriever := dsservice.ProvideDataSourceRetriever(sqlStore, features)
|
||||
dsService, err := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(),
|
||||
acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
||||
require.NoError(t, err)
|
||||
migService := ProvideDataSourceMigrationService(dsService, kvStore, features)
|
||||
return migService
|
||||
|
||||
@@ -293,15 +293,15 @@ overrides_path = overrides.yaml
|
||||
overrides_reload_period = 5s
|
||||
```
|
||||
|
||||
To overrides the default quota for a tenant, add the following to the overrides.yaml file:
|
||||
To override the default quota for a tenant, add the following to the `overrides.yaml` file:
|
||||
```yaml
|
||||
overrides:
|
||||
<NAMESPACE>:
|
||||
quotas:
|
||||
<GROUP>.<RESOURCE>:
|
||||
<GROUP>/<RESOURCE>:
|
||||
limit: 10
|
||||
```
|
||||
Unless otherwise set, the NAMESPACE when running locally is `default`.
|
||||
Unless otherwise set, the `NAMESPACE` when running locally is `default`.
|
||||
|
||||
To access quotas, use the following API endpoint:
|
||||
```
|
||||
@@ -806,8 +806,10 @@ flowchart TD
|
||||
|
||||
#### Setting Dual Writer Mode
|
||||
```ini
|
||||
[unified_storage.{resource}.{kind}.{group}]
|
||||
dualWriterMode = {0-5}
|
||||
; [unified_storage.{resource}.{group}]
|
||||
[unified_storage.dashboards.dashboard.grafana.app]
|
||||
; modes {0-5}
|
||||
dualWriterMode = 0
|
||||
```
|
||||
|
||||
#### Background Sync Configuration
|
||||
@@ -1376,4 +1378,3 @@ disable_data_migrations = false
|
||||
### Documentation
|
||||
|
||||
For detailed information about migration architecture, validators, and troubleshooting, refer to [migrations/README.md](./migrations/README.md).
|
||||
|
||||
@@ -11,7 +11,7 @@ INSERT INTO {{ .Ident "resource" }}
|
||||
{{ .Ident "previous_resource_version" }}
|
||||
)
|
||||
VALUES (
|
||||
COALESCE({{ .Arg .Value }}, ""),
|
||||
(SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}),
|
||||
{{ .Arg .GUID }},
|
||||
{{ .Arg .Group }},
|
||||
{{ .Arg .Resource }},
|
||||
@@ -19,13 +19,5 @@ VALUES (
|
||||
{{ .Arg .Name }},
|
||||
{{ .Arg .Action }},
|
||||
{{ .Arg .Folder }},
|
||||
CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE (
|
||||
SELECT {{ .Ident "resource_version" }}
|
||||
FROM {{ .Ident "resource" }}
|
||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
||||
AND {{ .Ident "name" }} = {{ .Arg .Name }}
|
||||
ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1
|
||||
) END
|
||||
{{ .Arg .PreviousRV }}
|
||||
);
|
||||
|
||||
@@ -7,9 +7,7 @@ INSERT INTO {{ .Ident "resource_history" }}
|
||||
{{ .Ident "namespace" }},
|
||||
{{ .Ident "name" }},
|
||||
{{ .Ident "action" }},
|
||||
{{ .Ident "folder" }},
|
||||
{{ .Ident "previous_resource_version" }},
|
||||
{{ .Ident "generation" }}
|
||||
{{ .Ident "folder" }}
|
||||
)
|
||||
VALUES (
|
||||
COALESCE({{ .Arg .Value }}, ""),
|
||||
@@ -19,26 +17,5 @@ VALUES (
|
||||
{{ .Arg .Namespace }},
|
||||
{{ .Arg .Name }},
|
||||
{{ .Arg .Action }},
|
||||
{{ .Arg .Folder }},
|
||||
CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE (
|
||||
SELECT {{ .Ident "resource_version" }}
|
||||
FROM {{ .Ident "resource_history" }}
|
||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
||||
AND {{ .Ident "name" }} = {{ .Arg .Name }}
|
||||
ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1
|
||||
) END,
|
||||
CASE
|
||||
WHEN {{ .Arg .Action }} = 1 THEN 1
|
||||
WHEN {{ .Arg .Action }} = 3 THEN 0
|
||||
ELSE 1 + (
|
||||
SELECT COUNT(1)
|
||||
FROM {{ .Ident "resource_history" }}
|
||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
||||
AND {{ .Ident "name" }} = {{ .Arg .Name }}
|
||||
)
|
||||
END
|
||||
{{ .Arg .Folder }}
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
UPDATE {{ .Ident "resource" }}
|
||||
SET
|
||||
{{ .Ident "value" }} = {{ .Arg .Value }},
|
||||
{{ .Ident "guid" }} = {{ .Arg .GUID }},
|
||||
{{ .Ident "value" }} = (SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}),
|
||||
{{ .Ident "action" }} = {{ .Arg .Action }},
|
||||
{{ .Ident "folder" }} = {{ .Arg .Folder }}
|
||||
{{ .Ident "folder" }} = {{ .Arg .Folder }},
|
||||
{{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }}
|
||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
UPDATE {{ .Ident "resource_history" }}
|
||||
SET
|
||||
{{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }},
|
||||
{{ .Ident "generation" }} = {{ .Arg .Generation }}
|
||||
WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }};
|
||||
@@ -12,6 +12,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/validation"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/db"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/dbutil"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
@@ -306,10 +309,6 @@ func (d *dataStore) GetResourceKeyAtRevision(ctx context.Context, key GetRequest
|
||||
return DataKey{}, fmt.Errorf("invalid get request key: %w", err)
|
||||
}
|
||||
|
||||
if rv == 0 {
|
||||
rv = math.MaxInt64
|
||||
}
|
||||
|
||||
listKey := ListRequestKey(key)
|
||||
|
||||
iter := d.ListResourceKeysAtRevision(ctx, ListRequestOptions{Key: listKey, ResourceVersion: rv})
|
||||
@@ -598,7 +597,7 @@ func ParseKey(key string) (DataKey, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Temporary while we need to support unified/sql/backend compatibility
|
||||
// Temporary while we need to support unified/sql/backend compatibility.
|
||||
// Remove once we stop using RvManager in storage_backend.go
|
||||
func ParseKeyWithGUID(key string) (DataKey, error) {
|
||||
parts := strings.Split(key, "/")
|
||||
@@ -815,3 +814,121 @@ func (d *dataStore) getGroupResources(ctx context.Context) ([]GroupResource, err
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// TODO: remove when backwards compatibility is no longer needed.
|
||||
var (
|
||||
sqlKVUpdateLegacyResourceHistory = mustTemplate("sqlkv_update_legacy_resource_history.sql")
|
||||
sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql")
|
||||
sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql")
|
||||
)
|
||||
|
||||
// TODO: remove when backwards compatibility is no longer needed.
|
||||
type sqlKVLegacySaveRequest struct {
|
||||
sqltemplate.SQLTemplate
|
||||
GUID string
|
||||
Group string
|
||||
Resource string
|
||||
Namespace string
|
||||
Name string
|
||||
Action int64
|
||||
Folder string
|
||||
PreviousRV int64
|
||||
}
|
||||
|
||||
func (req sqlKVLegacySaveRequest) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: remove when backwards compatibility is no longer needed.
|
||||
type sqlKVLegacyUpdateHistoryRequest struct {
|
||||
sqltemplate.SQLTemplate
|
||||
GUID string
|
||||
PreviousRV int64
|
||||
Generation int64
|
||||
}
|
||||
|
||||
func (req sqlKVLegacyUpdateHistoryRequest) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyBackwardsCompatibleChanges updates the `resource` and `resource_history` tables
|
||||
// to make sure the sqlkv implementation is backwards-compatible with the existing sql backend.
|
||||
// Specifically, it will update the `resource_history` table to include the previous resource version
|
||||
// and generation, which come from the `WriteEvent`, and also make the corresponding change on the
|
||||
// `resource` table, no longer used in the storage backend.
|
||||
//
|
||||
// TODO: remove when backwards compatibility is no longer needed.
|
||||
func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.Tx, event WriteEvent, key DataKey) error {
|
||||
kv, isSQLKV := d.kv.(*sqlKV)
|
||||
if !isSQLKV {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResourceHistory, sqlKVLegacyUpdateHistoryRequest{
|
||||
SQLTemplate: sqltemplate.New(kv.dialect),
|
||||
GUID: key.GUID,
|
||||
PreviousRV: event.PreviousRV,
|
||||
Generation: event.Object.GetGeneration(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("compatibility layer: failed to insert to resource: %w", err)
|
||||
}
|
||||
|
||||
var action int64
|
||||
switch key.Action {
|
||||
case DataActionCreated:
|
||||
action = 1
|
||||
case DataActionUpdated:
|
||||
action = 2
|
||||
case DataActionDeleted:
|
||||
action = 3
|
||||
}
|
||||
|
||||
switch key.Action {
|
||||
case DataActionCreated:
|
||||
_, err := dbutil.Exec(ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{
|
||||
SQLTemplate: sqltemplate.New(kv.dialect),
|
||||
GUID: key.GUID,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
Namespace: key.Namespace,
|
||||
Name: key.Name,
|
||||
Action: action,
|
||||
Folder: key.Folder,
|
||||
PreviousRV: event.PreviousRV,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("compatibility layer: failed to insert to resource: %w", err)
|
||||
}
|
||||
case DataActionUpdated:
|
||||
_, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{
|
||||
SQLTemplate: sqltemplate.New(kv.dialect),
|
||||
GUID: key.GUID,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
Namespace: key.Namespace,
|
||||
Name: key.Name,
|
||||
Folder: key.Folder,
|
||||
PreviousRV: event.PreviousRV,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("compatibility layer: failed to update resource: %w", err)
|
||||
}
|
||||
case DataActionDeleted:
|
||||
_, err := dbutil.Exec(ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{
|
||||
SQLTemplate: sqltemplate.New(kv.dialect),
|
||||
Resource: key.Resource,
|
||||
Namespace: key.Namespace,
|
||||
Name: key.Name,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("compatibility layer: failed to delete from resource: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -44,8 +44,6 @@ var (
|
||||
sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql")
|
||||
sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql")
|
||||
sqlKVInsertLegacyResourceHistory = mustTemplate("sqlkv_insert_legacy_resource_history.sql")
|
||||
sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql")
|
||||
sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql")
|
||||
sqlKVDeleteLegacyResource = mustTemplate("sqlkv_delete_legacy_resource.sql")
|
||||
sqlKVDelete = mustTemplate("sqlkv_delete.sql")
|
||||
sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql")
|
||||
@@ -157,26 +155,6 @@ func (req sqlKVSaveRequest) Validate() error {
|
||||
return req.sqlKVSectionKey.Validate()
|
||||
}
|
||||
|
||||
type sqlKVLegacySaveRequest struct {
|
||||
sqltemplate.SQLTemplate
|
||||
Value []byte
|
||||
GUID string
|
||||
Group string
|
||||
Resource string
|
||||
Namespace string
|
||||
Name string
|
||||
Action int64
|
||||
Folder string
|
||||
}
|
||||
|
||||
func (req sqlKVLegacySaveRequest) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (req sqlKVLegacySaveRequest) Results() ([]byte, error) {
|
||||
return req.Value, nil
|
||||
}
|
||||
|
||||
type sqlKVKeysRequest struct {
|
||||
sqltemplate.SQLTemplate
|
||||
sqlKVSection
|
||||
@@ -392,7 +370,7 @@ func (w *sqlWriteCloser) Close() error {
|
||||
// used to keep backwards compatibility between sql-based kvstore and unified/sql/backend
|
||||
tx, ok := rvmanager.TxFromCtx(w.ctx)
|
||||
if !ok {
|
||||
// temporary save for dataStore without rvmanager
|
||||
// temporary save for dataStore without rvmanager (non backwards-compatible)
|
||||
// we can use the same template as the event one after we:
|
||||
// - move PK from GUID to key_path
|
||||
// - remove all unnecessary columns (or at least their NOT NULL constraints)
|
||||
@@ -429,11 +407,12 @@ func (w *sqlWriteCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// special, temporary save that includes all the fields in resource_history that are not relevant for the kvstore,
|
||||
// as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that
|
||||
// component will be responsible for populating the resource_version and key_path columns
|
||||
// note that we are not touching resource_version table, neither the resource_version columns or the key_path column
|
||||
// as the RvManager will be responsible for this
|
||||
// special, temporary backwards-compatible save that includes all the fields in resource_history that are not relevant
|
||||
// for the kvstore, as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that
|
||||
// component will be responsible for populating the resource_version and key_path columns.
|
||||
// For full backwards-compatibility, the `Save` function needs to be called within a callback that updates the resource_history
|
||||
// table with `previous_resource_version` and `generation` and updates the `resource` table accordingly. See the
|
||||
// storage_backend for the full implementation.
|
||||
dataKey, err := ParseKeyWithGUID(w.sectionKey.Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse key: %w", err)
|
||||
@@ -448,7 +427,7 @@ func (w *sqlWriteCloser) Close() error {
|
||||
case DataActionDeleted:
|
||||
action = 3
|
||||
default:
|
||||
return fmt.Errorf("failed to parse key: %w", err)
|
||||
return fmt.Errorf("failed to parse key: invalid action")
|
||||
}
|
||||
|
||||
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{
|
||||
@@ -468,52 +447,6 @@ func (w *sqlWriteCloser) Close() error {
|
||||
return fmt.Errorf("failed to save to resource_history: %w", err)
|
||||
}
|
||||
|
||||
switch dataKey.Action {
|
||||
case DataActionCreated:
|
||||
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{
|
||||
SQLTemplate: sqltemplate.New(w.kv.dialect),
|
||||
Value: w.buf.Bytes(),
|
||||
GUID: dataKey.GUID,
|
||||
Group: dataKey.Group,
|
||||
Resource: dataKey.Resource,
|
||||
Namespace: dataKey.Namespace,
|
||||
Name: dataKey.Name,
|
||||
Action: action,
|
||||
Folder: dataKey.Folder,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert to resource: %w", err)
|
||||
}
|
||||
case DataActionUpdated:
|
||||
_, err = dbutil.Exec(w.ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{
|
||||
SQLTemplate: sqltemplate.New(w.kv.dialect),
|
||||
Value: w.buf.Bytes(),
|
||||
Group: dataKey.Group,
|
||||
Resource: dataKey.Resource,
|
||||
Namespace: dataKey.Namespace,
|
||||
Name: dataKey.Name,
|
||||
Action: action,
|
||||
Folder: dataKey.Folder,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update resource: %w", err)
|
||||
}
|
||||
case DataActionDeleted:
|
||||
_, err = dbutil.Exec(w.ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{
|
||||
SQLTemplate: sqltemplate.New(w.kv.dialect),
|
||||
Group: dataKey.Group,
|
||||
Resource: dataKey.Resource,
|
||||
Namespace: dataKey.Namespace,
|
||||
Name: dataKey.Name,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete from resource: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -332,11 +332,14 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
||||
dataKey.GUID = uuid.New().String()
|
||||
var err error
|
||||
rv, err = k.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) {
|
||||
err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value))
|
||||
if err != nil {
|
||||
if err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)); err != nil {
|
||||
return "", fmt.Errorf("failed to write data: %w", err)
|
||||
}
|
||||
|
||||
if err := k.dataStore.applyBackwardsCompatibleChanges(ctx, tx, event, dataKey); err != nil {
|
||||
return "", fmt.Errorf("failed to apply backwards compatible updates: %w", err)
|
||||
}
|
||||
|
||||
return dataKey.GUID, nil
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
|
||||
const pluginsDiscoveryJSON = `[
|
||||
{
|
||||
"version": "v0alpha1",
|
||||
"freshness": "Current",
|
||||
"resources": [
|
||||
{
|
||||
"resource": "metas",
|
||||
"responseKind": {
|
||||
"group": "",
|
||||
"kind": "Meta",
|
||||
"version": ""
|
||||
},
|
||||
"scope": "Namespaced",
|
||||
"singularResource": "meta",
|
||||
"subresources": [
|
||||
{
|
||||
"responseKind": {
|
||||
"group": "",
|
||||
"kind": "Meta",
|
||||
"version": ""
|
||||
},
|
||||
"subresource": "status",
|
||||
"verbs": [
|
||||
"get",
|
||||
"patch",
|
||||
"update"
|
||||
]
|
||||
}
|
||||
],
|
||||
"verbs": [
|
||||
"get",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "plugins",
|
||||
"responseKind": {
|
||||
"group": "",
|
||||
"kind": "Plugin",
|
||||
"version": ""
|
||||
},
|
||||
"scope": "Namespaced",
|
||||
"singularResource": "plugin",
|
||||
"subresources": [
|
||||
{
|
||||
"responseKind": {
|
||||
"group": "",
|
||||
"kind": "Plugin",
|
||||
"version": ""
|
||||
},
|
||||
"subresource": "status",
|
||||
"verbs": [
|
||||
"get",
|
||||
"patch",
|
||||
"update"
|
||||
]
|
||||
}
|
||||
],
|
||||
"verbs": [
|
||||
"create",
|
||||
"delete",
|
||||
"deletecollection",
|
||||
"get",
|
||||
"list",
|
||||
"patch",
|
||||
"update",
|
||||
"watch"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`
|
||||
|
||||
func setupHelper(t *testing.T, openFeatureAPIEnabled bool) *K8sTestHelper {
|
||||
t.Helper()
|
||||
helper := NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: true,
|
||||
DisableAnonymous: true,
|
||||
APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true",
|
||||
OpenFeatureAPIEnabled: openFeatureAPIEnabled,
|
||||
})
|
||||
t.Cleanup(func() { helper.Shutdown() })
|
||||
return helper
|
||||
}
|
||||
|
||||
func TestIntegrationAPIServerRuntimeConfig(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
t.Run("discovery with openfeature api enabled", func(t *testing.T) {
|
||||
helper := setupHelper(t, true)
|
||||
disco, err := helper.GetGroupVersionInfoJSON("features.grafana.app")
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `[
|
||||
{
|
||||
"freshness": "Current",
|
||||
"resources": [
|
||||
{
|
||||
"resource": "noop",
|
||||
"responseKind": {
|
||||
"group": "",
|
||||
"kind": "Status",
|
||||
"version": ""
|
||||
},
|
||||
"scope": "Namespaced",
|
||||
"singularResource": "noop",
|
||||
"verbs": [
|
||||
"get"
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": "v0alpha1"
|
||||
}
|
||||
]`, disco)
|
||||
|
||||
// plugins should still be discoverable
|
||||
disco, err = helper.GetGroupVersionInfoJSON("plugins.grafana.app")
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, pluginsDiscoveryJSON, disco)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("discovery with openfeature api false", func(t *testing.T) {
|
||||
helper := setupHelper(t, false)
|
||||
_, err := helper.GetGroupVersionInfoJSON("features.grafana.app")
|
||||
require.Error(t, err, "expected error when openfeature api is disabled")
|
||||
|
||||
// plugins should still be discoverable
|
||||
disco, err := helper.GetGroupVersionInfoJSON("plugins.grafana.app")
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, pluginsDiscoveryJSON, disco)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/tests/apis"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
@@ -177,6 +178,9 @@ func setupHelper(t *testing.T) *apis.K8sTestHelper {
|
||||
AppModeProduction: true,
|
||||
DisableAnonymous: true,
|
||||
APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true",
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagPluginStoreServiceLoading,
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { helper.Shutdown() })
|
||||
return helper
|
||||
|
||||
@@ -320,8 +320,9 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
|
||||
require.NoError(t, err)
|
||||
_, err = openFeatureSect.NewKey("enable_api", strconv.FormatBool(opts.OpenFeatureAPIEnabled))
|
||||
require.NoError(t, err)
|
||||
if !opts.OpenFeatureAPIEnabled {
|
||||
_, err = openFeatureSect.NewKey("provider", "static") // in practice, APIEnabled being false goes with features-service type, but trying to make tests work
|
||||
|
||||
if opts.OpenFeatureAPIEnabled {
|
||||
_, err = openFeatureSect.NewKey("provider", "static")
|
||||
require.NoError(t, err)
|
||||
_, err = openFeatureSect.NewKey("targetingKey", "grafana")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package exemplar
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
type Exemplar struct {
|
||||
Id string
|
||||
Value float64
|
||||
Timestamp int64
|
||||
}
|
||||
|
||||
func CreateExemplarFrame(labels map[string]string, exemplars []*Exemplar) *data.Frame {
|
||||
frame := data.NewFrame("exemplar")
|
||||
frame.Meta = &data.FrameMeta{
|
||||
DataTopic: data.DataTopicAnnotations,
|
||||
}
|
||||
fields := []*data.Field{
|
||||
data.NewField("Time", nil, []time.Time{}),
|
||||
data.NewField("Value", labels, []float64{}), // add labels here?
|
||||
data.NewField("Id", nil, []string{}),
|
||||
}
|
||||
fields[2].Config = &data.FieldConfig{
|
||||
DisplayName: "Profile ID",
|
||||
}
|
||||
for name := range labels {
|
||||
fields = append(fields, data.NewField(name, nil, []string{}))
|
||||
}
|
||||
frame.Fields = fields
|
||||
|
||||
for _, e := range exemplars {
|
||||
frame.AppendRow(time.UnixMilli(e.Timestamp), e.Value, e.Id)
|
||||
for name, value := range labels {
|
||||
field, _ := frame.FieldByName(name)
|
||||
if field != nil {
|
||||
field.Append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return frame
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package exemplar
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateExemplarFrame(t *testing.T) {
|
||||
exemplars := []*Exemplar{
|
||||
{Id: "1", Value: 1.0, Timestamp: 100},
|
||||
{Id: "2", Value: 2.0, Timestamp: 200},
|
||||
}
|
||||
labels := map[string]string{
|
||||
"foo": "bar",
|
||||
}
|
||||
frame := CreateExemplarFrame(labels, exemplars)
|
||||
|
||||
require.Equal(t, "exemplar", frame.Name)
|
||||
require.Equal(t, 4, len(frame.Fields))
|
||||
require.Equal(t, "Time", frame.Fields[0].Name)
|
||||
require.Equal(t, "Value", frame.Fields[1].Name)
|
||||
require.Equal(t, "Id", frame.Fields[2].Name)
|
||||
require.Equal(t, "foo", frame.Fields[3].Name)
|
||||
|
||||
rows, err := frame.RowLen()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, rows)
|
||||
row := frame.RowCopy(0)
|
||||
require.Equal(t, 4, len(row))
|
||||
require.Equal(t, 1.0, row[1])
|
||||
require.Equal(t, "1", row[2])
|
||||
require.Equal(t, "bar", row[3])
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -31,7 +33,7 @@ type ProfilingClient interface {
|
||||
ProfileTypes(ctx context.Context, start int64, end int64) ([]*ProfileType, error)
|
||||
LabelNames(ctx context.Context, labelSelector string, start int64, end int64) ([]string, error)
|
||||
LabelValues(ctx context.Context, label string, labelSelector string, start int64, end int64) ([]string, error)
|
||||
GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64) (*SeriesResponse, error)
|
||||
GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64, exemplarType typesv1.ExemplarType) (*SeriesResponse, error)
|
||||
GetProfile(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, maxNodes *int64) (*ProfileResponse, error)
|
||||
GetSpanProfile(ctx context.Context, profileTypeID string, labelSelector string, spanSelector []string, start int64, end int64, maxNodes *int64) (*ProfileResponse, error)
|
||||
}
|
||||
|
||||
+7
-4
@@ -32,6 +32,8 @@ type GrafanaPyroscopeDataQuery struct {
|
||||
Limit *int64 `json:"limit,omitempty"`
|
||||
// Sets the maximum number of nodes in the flamegraph.
|
||||
MaxNodes *int64 `json:"maxNodes,omitempty"`
|
||||
// If set to true, the response will contain annotations
|
||||
Annotations *bool `json:"annotations,omitempty"`
|
||||
// A unique identifier for the query within the list of targets.
|
||||
// In server side expressions, the refId is used as a variable name to identify results.
|
||||
// By default, the UI will assign A->Z; however setting meaningful names may be useful.
|
||||
@@ -41,8 +43,8 @@ type GrafanaPyroscopeDataQuery struct {
|
||||
// Specify the query flavor
|
||||
// TODO make this required and give it a default
|
||||
QueryType *string `json:"queryType,omitempty"`
|
||||
// If set to true, the response will contain annotations
|
||||
Annotations *bool `json:"annotations,omitempty"`
|
||||
// If set to true, exemplars will be requested
|
||||
IncludeExemplars bool `json:"includeExemplars"`
|
||||
// For mixed data sources the selected datasource is on the query level.
|
||||
// For non mixed scenarios this is undefined.
|
||||
// TODO find a better way to do this ^ that's friendly to schema
|
||||
@@ -53,7 +55,8 @@ type GrafanaPyroscopeDataQuery struct {
|
||||
// NewGrafanaPyroscopeDataQuery creates a new GrafanaPyroscopeDataQuery object.
|
||||
func NewGrafanaPyroscopeDataQuery() *GrafanaPyroscopeDataQuery {
|
||||
return &GrafanaPyroscopeDataQuery{
|
||||
LabelSelector: "{}",
|
||||
GroupBy: []string{},
|
||||
LabelSelector: "{}",
|
||||
GroupBy: []string{},
|
||||
IncludeExemplars: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,16 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
|
||||
|
||||
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
|
||||
"github.com/grafana/pyroscope/api/gen/proto/go/querier/v1/querierv1connect"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
|
||||
"github.com/grafana/pyroscope/api/gen/proto/go/querier/v1/querierv1connect"
|
||||
)
|
||||
|
||||
type ProfileType struct {
|
||||
@@ -49,6 +51,13 @@ type Point struct {
|
||||
// Milliseconds unix timestamp
|
||||
Timestamp int64
|
||||
Annotations []*typesv1.ProfileAnnotation
|
||||
Exemplars []*Exemplar
|
||||
}
|
||||
|
||||
type Exemplar struct {
|
||||
Id string
|
||||
Value uint64
|
||||
Timestamp int64
|
||||
}
|
||||
|
||||
type ProfileResponse struct {
|
||||
@@ -99,7 +108,7 @@ func (c *PyroscopeClient) ProfileTypes(ctx context.Context, start int64, end int
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64) (*SeriesResponse, error) {
|
||||
func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64, exemplarType typesv1.ExemplarType) (*SeriesResponse, error) {
|
||||
ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.GetSeries", trace.WithAttributes(attribute.String("profileTypeID", profileTypeID), attribute.String("labelSelector", labelSelector)))
|
||||
defer span.End()
|
||||
req := connect.NewRequest(&querierv1.SelectSeriesRequest{
|
||||
@@ -110,6 +119,7 @@ func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, l
|
||||
Step: step,
|
||||
GroupBy: groupBy,
|
||||
Limit: limit,
|
||||
ExemplarType: exemplarType,
|
||||
})
|
||||
|
||||
resp, err := c.connectClient.SelectSeries(ctx, req)
|
||||
@@ -137,6 +147,16 @@ func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, l
|
||||
Timestamp: p.Timestamp,
|
||||
Annotations: p.Annotations,
|
||||
}
|
||||
if len(p.Exemplars) > 0 {
|
||||
points[i].Exemplars = make([]*Exemplar, len(p.Exemplars))
|
||||
for j, e := range p.Exemplars {
|
||||
points[i].Exemplars[j] = &Exemplar{
|
||||
Id: e.ProfileId,
|
||||
Value: e.Value,
|
||||
Timestamp: e.Timestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
series[i] = &Series{
|
||||
|
||||
@@ -5,10 +5,11 @@ import (
|
||||
"testing"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
googlev1 "github.com/grafana/pyroscope/api/gen/proto/go/google/v1"
|
||||
querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
|
||||
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_PyroscopeClient(t *testing.T) {
|
||||
@@ -19,7 +20,7 @@ func Test_PyroscopeClient(t *testing.T) {
|
||||
|
||||
t.Run("GetSeries", func(t *testing.T) {
|
||||
limit := int64(42)
|
||||
resp, err := client.GetSeries(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, []string{}, &limit, 15)
|
||||
resp, err := client.GetSeries(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, []string{}, &limit, 15, typesv1.ExemplarType_EXEMPLAR_TYPE_NONE)
|
||||
require.Nil(t, err)
|
||||
|
||||
series := &SeriesResponse{
|
||||
@@ -32,6 +33,21 @@ func Test_PyroscopeClient(t *testing.T) {
|
||||
require.Equal(t, series, resp)
|
||||
})
|
||||
|
||||
t.Run("GetSeriesWithExemplars", func(t *testing.T) {
|
||||
limit := int64(42)
|
||||
resp, err := client.GetSeries(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, []string{}, &limit, 15, typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL)
|
||||
require.Nil(t, err)
|
||||
|
||||
series := &SeriesResponse{
|
||||
Series: []*Series{
|
||||
{Labels: []*LabelPair{{Name: "foo", Value: "bar"}}, Points: []*Point{{Timestamp: int64(1000), Value: 30, Exemplars: []*Exemplar{{Id: "id1", Value: 3, Timestamp: 1000}}}, {Timestamp: int64(2000), Value: 10, Exemplars: []*Exemplar{{Id: "id2", Value: 1, Timestamp: 2000}}}}},
|
||||
},
|
||||
Units: "short",
|
||||
Label: "alloc_objects",
|
||||
}
|
||||
require.Equal(t, series, resp)
|
||||
})
|
||||
|
||||
t.Run("GetProfile", func(t *testing.T) {
|
||||
maxNodes := int64(-1)
|
||||
resp, err := client.GetProfile(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, &maxNodes)
|
||||
@@ -115,6 +131,21 @@ func (f *FakePyroscopeConnectClient) SelectMergeStacktraces(ctx context.Context,
|
||||
|
||||
func (f *FakePyroscopeConnectClient) SelectSeries(ctx context.Context, req *connect.Request[querierv1.SelectSeriesRequest]) (*connect.Response[querierv1.SelectSeriesResponse], error) {
|
||||
f.Req = req
|
||||
if req.Msg.ExemplarType == typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL {
|
||||
return &connect.Response[querierv1.SelectSeriesResponse]{
|
||||
Msg: &querierv1.SelectSeriesResponse{
|
||||
Series: []*typesv1.Series{
|
||||
{
|
||||
Labels: []*typesv1.LabelPair{{Name: "foo", Value: "bar"}},
|
||||
Points: []*typesv1.Point{
|
||||
{Timestamp: int64(1000), Value: 30, Exemplars: []*typesv1.Exemplar{{Timestamp: int64(1000), Value: 3, ProfileId: "id1"}}},
|
||||
{Timestamp: int64(2000), Value: 10, Exemplars: []*typesv1.Exemplar{{Timestamp: int64(2000), Value: 1, ProfileId: "id2"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return &connect.Response[querierv1.SelectSeriesResponse]{
|
||||
Msg: &querierv1.SelectSeriesResponse{
|
||||
Series: []*typesv1.Series{
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/live"
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/exemplar"
|
||||
"github.com/xlab/treeprint"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
@@ -21,6 +22,8 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation"
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery"
|
||||
|
||||
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
|
||||
)
|
||||
|
||||
type queryModel struct {
|
||||
@@ -36,8 +39,12 @@ const (
|
||||
queryTypeProfile = string(dataquery.PyroscopeQueryTypeProfile)
|
||||
queryTypeMetrics = string(dataquery.PyroscopeQueryTypeMetrics)
|
||||
queryTypeBoth = string(dataquery.PyroscopeQueryTypeBoth)
|
||||
|
||||
exemplarsFeatureToggle = "profilesExemplars"
|
||||
)
|
||||
|
||||
var identityTransformation = func(value float64) float64 { return value }
|
||||
|
||||
// query processes single Pyroscope query transforming the response to data.Frame packaged in DataResponse
|
||||
func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) backend.DataResponse {
|
||||
ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.query", trace.WithAttributes(attribute.String("query_type", query.QueryType)))
|
||||
@@ -77,6 +84,10 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont
|
||||
logger.Error("Failed to parse the MinStep using default", "MinStep", dsJson.MinStep, "function", logEntrypoint())
|
||||
}
|
||||
}
|
||||
exemplarType := typesv1.ExemplarType_EXEMPLAR_TYPE_NONE
|
||||
if qm.IncludeExemplars && backend.GrafanaConfigFromContext(ctx).FeatureToggles().IsEnabled(exemplarsFeatureToggle) {
|
||||
exemplarType = typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL
|
||||
}
|
||||
seriesResp, err := d.client.GetSeries(
|
||||
gCtx,
|
||||
profileTypeId,
|
||||
@@ -86,6 +97,7 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont
|
||||
qm.GroupBy,
|
||||
qm.Limit,
|
||||
math.Max(query.Interval.Seconds(), parsedInterval.Seconds()),
|
||||
exemplarType,
|
||||
)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
@@ -475,6 +487,7 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration
|
||||
annotations := make([]*annotation.TimedAnnotation, 0)
|
||||
|
||||
for _, series := range resp.Series {
|
||||
exemplars := make([]*exemplar.Exemplar, 0)
|
||||
// We create separate data frames as the series may not have the same length
|
||||
frame := data.NewFrame("series")
|
||||
frameMeta := &data.FrameMeta{PreferredVisualization: "graph"}
|
||||
@@ -516,14 +529,20 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration
|
||||
|
||||
// Apply rate calculation for cumulative profiles
|
||||
value := point.Value
|
||||
transformation := identityTransformation
|
||||
if isCumulativeProfile(profileTypeID) && stepDurationSec > 0 {
|
||||
value = value / stepDurationSec
|
||||
transformation = func(value float64) float64 {
|
||||
return value / stepDurationSec
|
||||
}
|
||||
|
||||
// Convert CPU nanoseconds to cores
|
||||
if isCPUTimeProfile(profileTypeID) {
|
||||
value = value / 1e9
|
||||
transformation = func(value float64) float64 {
|
||||
return value / stepDurationSec / 1e9
|
||||
}
|
||||
}
|
||||
}
|
||||
value = transformation(value)
|
||||
valueField.Append(value)
|
||||
if withAnnotations {
|
||||
for _, a := range point.Annotations {
|
||||
@@ -533,10 +552,22 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, e := range point.Exemplars {
|
||||
exemplars = append(exemplars, &exemplar.Exemplar{
|
||||
Id: e.Id,
|
||||
Value: transformation(float64(e.Value)),
|
||||
Timestamp: e.Timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
frame.Fields = fields
|
||||
frames = append(frames, frame)
|
||||
|
||||
if len(exemplars) > 0 {
|
||||
frame := exemplar.CreateExemplarFrame(labels, exemplars)
|
||||
frames = append(frames, frame)
|
||||
}
|
||||
}
|
||||
|
||||
if len(annotations) > 0 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
|
||||
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
|
||||
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation"
|
||||
@@ -487,10 +488,21 @@ func Test_seriesToDataFrame(t *testing.T) {
|
||||
require.Nil(t, frames[0].Meta.Custom)
|
||||
})
|
||||
|
||||
t.Run("CPU time conversion to cores", func(t *testing.T) {
|
||||
t.Run("CPU time conversion to cores with exemplars", func(t *testing.T) {
|
||||
series := &SeriesResponse{
|
||||
Series: []*Series{
|
||||
{Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 3000000000}, {Timestamp: int64(2000), Value: 1500000000}}}, // 3s and 1.5s in nanoseconds
|
||||
{
|
||||
Labels: []*LabelPair{}, Points: []*Point{
|
||||
{
|
||||
Timestamp: int64(1000), Value: 3000000000, // 3s in nanoseconds
|
||||
Exemplars: []*Exemplar{{Value: 300000000, Timestamp: 1000}}, // 0.3s in nanoseconds
|
||||
},
|
||||
{
|
||||
Timestamp: int64(2000), Value: 1500000000, // 1.5s in nanoseconds
|
||||
Exemplars: []*Exemplar{{Value: 150000000, Timestamp: 1000}}, // 0.15s in nanoseconds
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Units: "ns",
|
||||
Label: "cpu",
|
||||
@@ -498,19 +510,32 @@ func Test_seriesToDataFrame(t *testing.T) {
|
||||
// should convert nanoseconds to cores and set unit to "cores"
|
||||
frames, err := seriesToDataFrames(series, false, 15.0, "process_cpu:cpu:nanoseconds:cpu:nanoseconds")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(frames))
|
||||
require.Equal(t, 2, len(frames))
|
||||
|
||||
require.Equal(t, "cores", frames[0].Fields[1].Config.Unit)
|
||||
|
||||
// Check values were converted: 3000000000/15/1e9 = 0.2 cores/sec, 1500000000/15/1e9 = 0.1 cores/sec
|
||||
values := fieldValues[float64](frames[0].Fields[1])
|
||||
require.Equal(t, []float64{0.2, 0.1}, values)
|
||||
// Check exemplar values were converted: 300000000/15/1e9 = 0.02 cores/sec, 150000000/15/1e9 = 0.01 cores/sec
|
||||
exemplarValues := fieldValues[float64](frames[1].Fields[1])
|
||||
require.Equal(t, []float64{0.02, 0.01}, exemplarValues)
|
||||
})
|
||||
|
||||
t.Run("Memory allocation unit conversion to bytes/sec", func(t *testing.T) {
|
||||
series := &SeriesResponse{
|
||||
Series: []*Series{
|
||||
{Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 150000000}, {Timestamp: int64(2000), Value: 300000000}}}, // 150 MB, 300 MB
|
||||
{
|
||||
Labels: []*LabelPair{}, Points: []*Point{
|
||||
{
|
||||
Timestamp: int64(1000), Value: 150000000, // 150 MB
|
||||
Exemplars: []*Exemplar{{Value: 15000000, Timestamp: 1000}}, // 15 MB
|
||||
}, {
|
||||
Timestamp: int64(2000), Value: 300000000, // 300 MB
|
||||
Exemplars: []*Exemplar{{Value: 30000000, Timestamp: 1000}}, // 30 MB
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Units: "bytes",
|
||||
Label: "memory_alloc",
|
||||
@@ -518,19 +543,33 @@ func Test_seriesToDataFrame(t *testing.T) {
|
||||
// should convert bytes to binBps and apply rate calculation
|
||||
frames, err := seriesToDataFrames(series, false, 15.0, "memory:alloc_space:bytes:space:bytes")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(frames))
|
||||
require.Equal(t, 2, len(frames))
|
||||
|
||||
require.Equal(t, "binBps", frames[0].Fields[1].Config.Unit)
|
||||
|
||||
// Check values were rate calculated: 150000000/15 = 10000000, 300000000/15 = 20000000
|
||||
values := fieldValues[float64](frames[0].Fields[1])
|
||||
require.Equal(t, []float64{10000000, 20000000}, values)
|
||||
// Check exemplar values were rate calculated: 15000000/15 = 1000000, 30000000/15 = 2000000
|
||||
exemplarValues := fieldValues[float64](frames[1].Fields[1])
|
||||
require.Equal(t, []float64{1000000, 2000000}, exemplarValues)
|
||||
})
|
||||
|
||||
t.Run("Count-based profile unit conversion to ops/sec", func(t *testing.T) {
|
||||
series := &SeriesResponse{
|
||||
Series: []*Series{
|
||||
{Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 1500}, {Timestamp: int64(2000), Value: 3000}}}, // 1500, 3000 contentions
|
||||
{
|
||||
Labels: []*LabelPair{}, Points: []*Point{
|
||||
{
|
||||
Timestamp: int64(1000), Value: 1500, // 1500 contentions
|
||||
Exemplars: []*Exemplar{{Value: 150, Timestamp: 1000}}, // 150 contentions
|
||||
|
||||
}, {
|
||||
Timestamp: int64(2000), Value: 3000, // 3000 contentions
|
||||
Exemplars: []*Exemplar{{Value: 300, Timestamp: 1000}}, // 300 contentions
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Units: "short",
|
||||
Label: "contentions",
|
||||
@@ -538,13 +577,16 @@ func Test_seriesToDataFrame(t *testing.T) {
|
||||
// should convert short to ops and apply rate calculation
|
||||
frames, err := seriesToDataFrames(series, false, 15.0, "mutex:contentions:count:contentions:count")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(frames))
|
||||
require.Equal(t, 2, len(frames))
|
||||
|
||||
require.Equal(t, "ops", frames[0].Fields[1].Config.Unit)
|
||||
|
||||
// Check values were rate calculated: 1500/15 = 100, 3000/15 = 200
|
||||
values := fieldValues[float64](frames[0].Fields[1])
|
||||
require.Equal(t, []float64{100, 200}, values)
|
||||
// Check exemplar values were rate calculated: 150/15 = 10, 300/15 = 20
|
||||
exemplarValues := fieldValues[float64](frames[1].Fields[1])
|
||||
require.Equal(t, []float64{10, 20}, exemplarValues)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -605,7 +647,7 @@ func (f *FakeClient) GetSpanProfile(ctx context.Context, profileTypeID, labelSel
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *FakeClient) GetSeries(ctx context.Context, profileTypeID, labelSelector string, start, end int64, groupBy []string, limit *int64, step float64) (*SeriesResponse, error) {
|
||||
func (f *FakeClient) GetSeries(ctx context.Context, profileTypeID, labelSelector string, start, end int64, groupBy []string, limit *int64, step float64, exemplarType typesv1.ExemplarType) (*SeriesResponse, error) {
|
||||
f.Args = []any{profileTypeID, labelSelector, start, end, groupBy, step}
|
||||
return &SeriesResponse{
|
||||
Series: []*Series{
|
||||
|
||||
@@ -3,7 +3,8 @@ import { render, screen, userEvent, waitFor } from 'test/test-utils';
|
||||
import { byLabelText, byRole, byText } from 'testing-library-selector';
|
||||
|
||||
import { setPluginLinksHook } from '@grafana/runtime';
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import server from '@grafana/test-utils/server';
|
||||
import { mockAlertRuleApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
mockPluginLinkExtension,
|
||||
mockPromAlertingRule,
|
||||
mockRulerGrafanaRecordingRule,
|
||||
mockRulerGrafanaRule,
|
||||
} from '../../mocks';
|
||||
import { grafanaRulerRule } from '../../mocks/grafanaRulerApi';
|
||||
import { grantPermissionsHelper } from '../../test/test-utils';
|
||||
@@ -130,6 +132,8 @@ const dataSources = {
|
||||
};
|
||||
|
||||
describe('RuleViewer', () => {
|
||||
const api = mockAlertRuleApi(server);
|
||||
|
||||
beforeEach(() => {
|
||||
setupDataSources(...Object.values(dataSources));
|
||||
});
|
||||
@@ -249,19 +253,22 @@ describe('RuleViewer', () => {
|
||||
|
||||
expect(screen.getAllByRole('row')).toHaveLength(7);
|
||||
expect(screen.getAllByRole('row')[1]).toHaveTextContent(/6Provisioning2025-01-18 04:35:17/i);
|
||||
expect(screen.getAllByRole('row')[1]).toHaveTextContent('+3-3Latest');
|
||||
expect(screen.getAllByRole('row')[1]).toHaveTextContent('Updated by provisioning service');
|
||||
expect(screen.getAllByRole('row')[1]).toHaveTextContent('+4-3Latest');
|
||||
|
||||
expect(screen.getAllByRole('row')[2]).toHaveTextContent(/5Alerting2025-01-17 04:35:17/i);
|
||||
expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-5');
|
||||
expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-6');
|
||||
|
||||
expect(screen.getAllByRole('row')[3]).toHaveTextContent(/4different user2025-01-16 04:35:17/i);
|
||||
expect(screen.getAllByRole('row')[3]).toHaveTextContent('+5-5');
|
||||
expect(screen.getAllByRole('row')[3]).toHaveTextContent('Changed alert title and thresholds');
|
||||
expect(screen.getAllByRole('row')[3]).toHaveTextContent('+6-5');
|
||||
|
||||
expect(screen.getAllByRole('row')[4]).toHaveTextContent(/3user12025-01-15 04:35:17/i);
|
||||
expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-9');
|
||||
expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-10');
|
||||
|
||||
expect(screen.getAllByRole('row')[5]).toHaveTextContent(/2User ID foo2025-01-14 04:35:17/i);
|
||||
expect(screen.getAllByRole('row')[5]).toHaveTextContent('+11-7');
|
||||
expect(screen.getAllByRole('row')[5]).toHaveTextContent('Updated evaluation interval and routing');
|
||||
expect(screen.getAllByRole('row')[5]).toHaveTextContent('+12-7');
|
||||
|
||||
expect(screen.getAllByRole('row')[6]).toHaveTextContent(/1Unknown 2025-01-13 04:35:17/i);
|
||||
|
||||
@@ -275,9 +282,10 @@ describe('RuleViewer', () => {
|
||||
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
|
||||
expect(await screen.findByRole('button', { name: /Compare versions/i })).toBeDisabled();
|
||||
|
||||
expect(screen.getByRole('cell', { name: /provisioning/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /alerting/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /Unknown/i })).toBeInTheDocument();
|
||||
// Check for special updated_by values - use getAllByRole since some text appears in multiple columns
|
||||
expect(screen.getAllByRole('cell', { name: /provisioning/i }).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('cell', { name: /^alerting$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /^Unknown$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /user id foo/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -321,6 +329,47 @@ describe('RuleViewer', () => {
|
||||
await renderRuleViewer(rule, ruleIdentifier);
|
||||
expect(screen.queryByText('Labels')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Notes column when versions have messages', async () => {
|
||||
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
|
||||
|
||||
expect(await screen.findByRole('columnheader', { name: /Notes/i })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('row')).toHaveLength(7); // 1 header + 6 data rows
|
||||
expect(screen.getByRole('cell', { name: /Updated by provisioning service/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /Changed alert title and thresholds/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /Updated evaluation interval and routing/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show Notes column when no versions have messages', async () => {
|
||||
const versionsWithoutMessages = [
|
||||
mockRulerGrafanaRule(
|
||||
{},
|
||||
{
|
||||
uid: grafanaRulerRule.grafana_alert.uid,
|
||||
version: 2,
|
||||
updated: '2025-01-14T09:35:17.000Z',
|
||||
updated_by: { uid: 'foo', name: '' },
|
||||
}
|
||||
),
|
||||
mockRulerGrafanaRule(
|
||||
{},
|
||||
{
|
||||
uid: grafanaRulerRule.grafana_alert.uid,
|
||||
version: 1,
|
||||
updated: '2025-01-13T09:35:17.000Z',
|
||||
updated_by: null,
|
||||
}
|
||||
),
|
||||
];
|
||||
api.getAlertRuleVersionHistory(grafanaRulerRule.grafana_alert.uid, versionsWithoutMessages);
|
||||
|
||||
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
|
||||
|
||||
await screen.findByRole('button', { name: /Compare versions/i });
|
||||
|
||||
expect(screen.getAllByRole('row')).toHaveLength(3); // 1 header + 2 data rows
|
||||
expect(screen.queryByRole('columnheader', { name: /Notes/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+24
-2
@@ -1,8 +1,9 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text } from '@grafana/ui';
|
||||
import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { computeVersionDiff } from 'app/features/alerting/unified/utils/diff';
|
||||
import { RuleIdentifier } from 'app/types/unified-alerting';
|
||||
@@ -33,6 +34,7 @@ export function VersionHistoryTable({
|
||||
onRestoreError,
|
||||
canRestore,
|
||||
}: VersionHistoryTableProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [ruleToRestore, setRuleToRestore] = useState<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>();
|
||||
const ruleToRestoreUid = ruleToRestore?.grafana_alert?.uid ?? '';
|
||||
@@ -41,6 +43,8 @@ export function VersionHistoryTable({
|
||||
[ruleToRestoreUid]
|
||||
);
|
||||
|
||||
const hasAnyNotes = useMemo(() => ruleVersions.some((v) => v.grafana_alert.message), [ruleVersions]);
|
||||
|
||||
const showConfirmation = (ruleToRestore: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
|
||||
setShowConfirmModal(true);
|
||||
setRuleToRestore(ruleToRestore);
|
||||
@@ -52,6 +56,15 @@ export function VersionHistoryTable({
|
||||
|
||||
const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown');
|
||||
|
||||
const notesColumn: Column<RulerGrafanaRuleDTO<GrafanaRuleDefinition>> = {
|
||||
id: 'notes',
|
||||
header: t('core.versionHistory.table.notes', 'Notes'),
|
||||
cell: ({ row }) => {
|
||||
const message = row.original.grafana_alert.message;
|
||||
return message || null;
|
||||
},
|
||||
};
|
||||
|
||||
const columns: Array<Column<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>> = [
|
||||
{
|
||||
disableGrow: true,
|
||||
@@ -91,9 +104,12 @@ export function VersionHistoryTable({
|
||||
if (!value) {
|
||||
return unknown;
|
||||
}
|
||||
return dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')';
|
||||
return (
|
||||
<span className={styles.nowrap}>{dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')'}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
...(hasAnyNotes ? [notesColumn] : []),
|
||||
{
|
||||
id: 'diff',
|
||||
disableGrow: true,
|
||||
@@ -179,3 +195,9 @@ export function VersionHistoryTable({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = () => ({
|
||||
nowrap: css({
|
||||
whiteSpace: 'nowrap',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ export const getFormFieldsForSilence = (silence: Silence): SilenceFormFields =>
|
||||
startsAt: interval.start.toISOString(),
|
||||
endsAt: interval.end.toISOString(),
|
||||
comment: silence.comment,
|
||||
createdBy: silence.createdBy,
|
||||
createdBy: isExpired ? contextSrv.user.name : silence.createdBy,
|
||||
duration: intervalToAbbreviatedDurationString(interval),
|
||||
isRegex: false,
|
||||
matchers: silence.matchers?.map(matcherToMatcherField) || [],
|
||||
|
||||
@@ -154,6 +154,7 @@ export const rulerRuleVersionHistoryHandler = () => {
|
||||
uid: 'service',
|
||||
name: '',
|
||||
};
|
||||
draft.grafana_alert.message = 'Updated by provisioning service';
|
||||
}),
|
||||
produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
|
||||
draft.grafana_alert.version = 5;
|
||||
@@ -171,6 +172,7 @@ export const rulerRuleVersionHistoryHandler = () => {
|
||||
uid: 'different',
|
||||
name: 'different user',
|
||||
};
|
||||
draft.grafana_alert.message = 'Changed alert title and thresholds';
|
||||
}),
|
||||
produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
|
||||
draft.grafana_alert.version = 3;
|
||||
@@ -193,6 +195,7 @@ export const rulerRuleVersionHistoryHandler = () => {
|
||||
uid: 'foo',
|
||||
name: '',
|
||||
};
|
||||
draft.grafana_alert.message = 'Updated evaluation interval and routing';
|
||||
}),
|
||||
produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
|
||||
draft.grafana_alert.version = 1;
|
||||
|
||||
@@ -39,7 +39,7 @@ export function RecentlyViewedDashboards() {
|
||||
retry();
|
||||
};
|
||||
|
||||
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
|
||||
if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -76,10 +76,6 @@ export function RecentlyViewedDashboards() {
|
||||
</>
|
||||
)}
|
||||
{loading && <Spinner />}
|
||||
{/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */}
|
||||
{!loading && recentDashboards.length === 0 && (
|
||||
<Text>{t('browse-dashboards.recently-viewed.empty', 'Nothing viewed yet')}</Text>
|
||||
)}
|
||||
|
||||
{!loading && recentDashboards.length > 0 && (
|
||||
<ul className={styles.list}>
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('PanelTimeRange', () => {
|
||||
expect(panelTime.state.value.to.format('Z')).toBe('+00:00'); // UTC
|
||||
});
|
||||
|
||||
it('should handle invalid time reference in timeShift', () => {
|
||||
it('should handle invalid time reference in timeShift with relative time range', () => {
|
||||
const panelTime = new PanelTimeRange({ timeShift: 'now-1d' });
|
||||
|
||||
buildAndActivateSceneFor(panelTime);
|
||||
@@ -139,6 +139,22 @@ describe('PanelTimeRange', () => {
|
||||
expect(panelTime.state.to).toBe('now');
|
||||
});
|
||||
|
||||
it('should handle invalid time reference in timeShift with absolute time range', () => {
|
||||
const panelTime = new PanelTimeRange({ timeShift: 'now-1d' });
|
||||
const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime });
|
||||
const absoluteFrom = '2019-02-11T10:00:00.000Z';
|
||||
const absoluteTo = '2019-02-11T16:00:00.000Z';
|
||||
const scene = new SceneFlexLayout({
|
||||
$timeRange: new SceneTimeRange({ from: absoluteFrom, to: absoluteTo }),
|
||||
children: [new SceneFlexItem({ body: panel })],
|
||||
});
|
||||
activateFullSceneTree(scene);
|
||||
|
||||
expect(panelTime.state.timeInfo).toBe('invalid timeshift');
|
||||
expect(panelTime.state.from).toBe(absoluteFrom);
|
||||
expect(panelTime.state.to).toBe(absoluteTo);
|
||||
});
|
||||
|
||||
it('should handle invalid time reference in timeShift combined with timeFrom', () => {
|
||||
const panelTime = new PanelTimeRange({
|
||||
timeFrom: 'now-2h',
|
||||
@@ -153,6 +169,66 @@ describe('PanelTimeRange', () => {
|
||||
expect(panelTime.state.to).toBe('now');
|
||||
});
|
||||
|
||||
describe('from/to state format for liveNow compatibility', () => {
|
||||
it('should store relative strings in from/to when timeShift is applied to relative time range', () => {
|
||||
const panelTime = new PanelTimeRange({ timeShift: '2h' });
|
||||
|
||||
buildAndActivateSceneFor(panelTime);
|
||||
|
||||
expect(panelTime.state.from).toBe('now-6h-2h');
|
||||
expect(panelTime.state.to).toBe('now-2h');
|
||||
expect(panelTime.state.value.raw.from).toBe('now-6h-2h');
|
||||
expect(panelTime.state.value.raw.to).toBe('now-2h');
|
||||
});
|
||||
|
||||
it('should store relative strings when both timeFrom and timeShift are applied', () => {
|
||||
const panelTime = new PanelTimeRange({ timeFrom: '2h', timeShift: '1h' });
|
||||
|
||||
buildAndActivateSceneFor(panelTime);
|
||||
|
||||
expect(panelTime.state.from).toBe('now-2h-1h');
|
||||
expect(panelTime.state.to).toBe('now-1h');
|
||||
});
|
||||
|
||||
it('should store ISO strings when timeShift is applied to absolute time range', () => {
|
||||
const panelTime = new PanelTimeRange({ timeShift: '1h' });
|
||||
const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime });
|
||||
const absoluteFrom = '2019-02-11T10:00:00.000Z';
|
||||
const absoluteTo = '2019-02-11T16:00:00.000Z';
|
||||
const scene = new SceneFlexLayout({
|
||||
$timeRange: new SceneTimeRange({ from: absoluteFrom, to: absoluteTo }),
|
||||
children: [new SceneFlexItem({ body: panel })],
|
||||
});
|
||||
activateFullSceneTree(scene);
|
||||
|
||||
expect(panelTime.state.from).toBe('2019-02-11T09:00:00.000Z');
|
||||
expect(panelTime.state.to).toBe('2019-02-11T15:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should update from/to when ancestor time range changes', () => {
|
||||
const panelTime = new PanelTimeRange({ timeShift: '1h' });
|
||||
const sceneTimeRange = new SceneTimeRange({ from: 'now-6h', to: 'now' });
|
||||
const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime });
|
||||
const scene = new SceneFlexLayout({
|
||||
$timeRange: sceneTimeRange,
|
||||
children: [new SceneFlexItem({ body: panel })],
|
||||
});
|
||||
activateFullSceneTree(scene);
|
||||
|
||||
expect(panelTime.state.from).toBe('now-6h-1h');
|
||||
expect(panelTime.state.to).toBe('now-1h');
|
||||
|
||||
sceneTimeRange.onTimeRangeChange({
|
||||
from: dateTime('2019-02-11T12:00:00.000Z'),
|
||||
to: dateTime('2019-02-11T18:00:00.000Z'),
|
||||
raw: { from: 'now-12h', to: 'now' },
|
||||
});
|
||||
|
||||
expect(panelTime.state.from).toBe('now-12h-1h');
|
||||
expect(panelTime.state.to).toBe('now-1h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onTimeRangeChange', () => {
|
||||
it('should reverse timeShift when updating time range', () => {
|
||||
const oneHourShift = '1h';
|
||||
|
||||
@@ -81,7 +81,19 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
|
||||
}
|
||||
|
||||
const overrideResult = this.getTimeOverride(timeRange.value);
|
||||
this.setState({ value: overrideResult.timeRange, timeInfo: overrideResult.timeInfo });
|
||||
const { timeRange: overrideTimeRange } = overrideResult;
|
||||
this.setState({
|
||||
value: overrideTimeRange,
|
||||
timeInfo: overrideResult.timeInfo,
|
||||
from:
|
||||
typeof overrideTimeRange.raw.from === 'string'
|
||||
? overrideTimeRange.raw.from
|
||||
: overrideTimeRange.raw.from.toISOString(),
|
||||
to:
|
||||
typeof overrideTimeRange.raw.to === 'string'
|
||||
? overrideTimeRange.raw.to
|
||||
: overrideTimeRange.raw.to.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Get a time shifted request to compare with the primary request.
|
||||
@@ -153,10 +165,10 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
|
||||
|
||||
// Only evaluate if the timeFrom if parent time is relative
|
||||
if (rangeUtil.isRelativeTimeRange(parentTimeRange.raw)) {
|
||||
const timeZone = this.getTimeZone();
|
||||
const timezone = this.getTimeZone();
|
||||
newTimeData.timeRange = {
|
||||
from: dateMath.parse(timeFromInfo.from, undefined, timeZone)!,
|
||||
to: dateMath.parse(timeFromInfo.to, undefined, timeZone)!,
|
||||
from: dateMath.toDateTime(timeFromInfo.from, { timezone })!,
|
||||
to: dateMath.toDateTime(timeFromInfo.to, { timezone })!,
|
||||
raw: { from: timeFromInfo.from, to: timeFromInfo.to },
|
||||
};
|
||||
infoBlocks.push(timeFromInfo.display);
|
||||
@@ -172,18 +184,39 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
|
||||
return newTimeData;
|
||||
}
|
||||
|
||||
const timeShift = '-' + timeShiftInterpolated;
|
||||
infoBlocks.push('timeshift ' + timeShift);
|
||||
const shift = '-' + timeShiftInterpolated;
|
||||
infoBlocks.push('timeshift ' + shift);
|
||||
|
||||
const from = dateMath.parseDateMath(timeShift, newTimeData.timeRange.from, false)!;
|
||||
const to = dateMath.parseDateMath(timeShift, newTimeData.timeRange.to, true)!;
|
||||
if (rangeUtil.isRelativeTimeRange(newTimeData.timeRange.raw)) {
|
||||
const timezone = this.getTimeZone();
|
||||
|
||||
if (!from || !to) {
|
||||
newTimeData.timeInfo = 'invalid timeshift';
|
||||
return newTimeData;
|
||||
const rawFromShifted = `${newTimeData.timeRange.raw.from}${shift}`;
|
||||
const rawToShifted = `${newTimeData.timeRange.raw.to}${shift}`;
|
||||
|
||||
const from = dateMath.toDateTime(rawFromShifted, { timezone });
|
||||
const to = dateMath.toDateTime(rawToShifted, { timezone });
|
||||
|
||||
if (!from || !to) {
|
||||
newTimeData.timeInfo = 'invalid timeshift';
|
||||
return newTimeData;
|
||||
}
|
||||
|
||||
newTimeData.timeRange = {
|
||||
from,
|
||||
to,
|
||||
raw: { from: rawFromShifted, to: rawToShifted },
|
||||
};
|
||||
} else {
|
||||
const from = dateMath.parseDateMath(shift, newTimeData.timeRange.from, false);
|
||||
const to = dateMath.parseDateMath(shift, newTimeData.timeRange.to, true);
|
||||
|
||||
if (!from || !to) {
|
||||
newTimeData.timeInfo = 'invalid timeshift';
|
||||
return newTimeData;
|
||||
}
|
||||
|
||||
newTimeData.timeRange = { from, to, raw: { from, to } };
|
||||
}
|
||||
|
||||
newTimeData.timeRange = { from, to, raw: { from, to } };
|
||||
}
|
||||
|
||||
if (compareWith) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { lastValueFrom, map } from 'rxjs';
|
||||
|
||||
import { config, getBackendSrv } from '@grafana/runtime';
|
||||
import { config, getBackendSrv, FetchResponse } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { DashboardDataDTO, DashboardDTO } from 'app/types/dashboard';
|
||||
import { DashboardDTO, SnapshotSpec } from 'app/types/dashboard';
|
||||
|
||||
import { getAPINamespace } from '../../../api/utils';
|
||||
|
||||
@@ -82,12 +82,11 @@ interface DashboardSnapshotList {
|
||||
items: K8sSnapshotResource[];
|
||||
}
|
||||
|
||||
// Response from the /dashboard subresource - returns a Dashboard with raw dashboard data in spec
|
||||
interface K8sDashboardSubresource {
|
||||
interface K8sDashboardSnapshot {
|
||||
apiVersion: string;
|
||||
kind: 'Dashboard';
|
||||
kind: 'Snapshot';
|
||||
metadata: K8sMetadata;
|
||||
spec: DashboardDataDTO;
|
||||
spec: SnapshotSpec;
|
||||
}
|
||||
|
||||
class K8sAPI implements DashboardSnapshotSrv {
|
||||
@@ -129,45 +128,32 @@ class K8sAPI implements DashboardSnapshotSrv {
|
||||
const token = `??? TODO, get anon token for snapshots (${contextSrv.user?.name}) ???`;
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Fetch both snapshot metadata and dashboard content in parallel
|
||||
const [snapshotResponse, dashboardResponse] = await Promise.all([
|
||||
lastValueFrom(
|
||||
getBackendSrv().fetch<K8sSnapshotResource>({
|
||||
return lastValueFrom(
|
||||
getBackendSrv()
|
||||
.fetch<K8sDashboardSnapshot>({
|
||||
url: this.url + '/' + uid,
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
})
|
||||
),
|
||||
lastValueFrom(
|
||||
getBackendSrv().fetch<K8sDashboardSubresource>({
|
||||
url: this.url + '/' + uid + '/dashboard',
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
const snapshot = snapshotResponse.data;
|
||||
const dashboard = dashboardResponse.data;
|
||||
|
||||
return {
|
||||
dashboard: dashboard.spec,
|
||||
meta: {
|
||||
isSnapshot: true,
|
||||
canSave: false,
|
||||
canEdit: false,
|
||||
canAdmin: false,
|
||||
canStar: false,
|
||||
canShare: false,
|
||||
canDelete: false,
|
||||
isFolder: false,
|
||||
provisioned: false,
|
||||
created: snapshot.metadata.creationTimestamp,
|
||||
expires: snapshot.spec.expires?.toString(),
|
||||
k8s: snapshot.metadata,
|
||||
},
|
||||
};
|
||||
.pipe(
|
||||
map((response: FetchResponse<K8sDashboardSnapshot>) => {
|
||||
return {
|
||||
dashboard: response.data.spec.dashboard,
|
||||
meta: {
|
||||
isSnapshot: true,
|
||||
canSave: false,
|
||||
canEdit: false,
|
||||
canAdmin: false,
|
||||
canStar: false,
|
||||
canShare: false,
|
||||
canDelete: false,
|
||||
isFolder: false,
|
||||
provisioned: false,
|
||||
},
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/internal';
|
||||
import { LogsFrame } from 'app/features/logs/logsFrame';
|
||||
import { DATAPLANE_ID_NAME, LogsFrame } from 'app/features/logs/logsFrame';
|
||||
|
||||
import { getFieldLinksForExplore } from '../utils/links';
|
||||
|
||||
@@ -154,9 +154,9 @@ export function LogsTable(props: Props) {
|
||||
},
|
||||
});
|
||||
// `getLinks` and `applyFieldOverrides` are taken from TableContainer.tsx
|
||||
for (const [index, field] of frameWithOverrides.fields.entries()) {
|
||||
for (const [fieldIdx, field] of frameWithOverrides.fields.entries()) {
|
||||
// Hide ID field from visualization (it's only needed for row matching)
|
||||
if (logsFrame?.idField && (field.name === logsFrame.idField.name || field.name === 'id')) {
|
||||
if (logsFrame?.idField && (field.name === logsFrame.idField.name || field.name === DATAPLANE_ID_NAME)) {
|
||||
field.config = {
|
||||
...field.config,
|
||||
custom: {
|
||||
@@ -180,7 +180,7 @@ export function LogsTable(props: Props) {
|
||||
};
|
||||
|
||||
// For the first field (time), wrap the cell to include action buttons
|
||||
const isFirstField = index === 0;
|
||||
const isFirstField = fieldIdx === 0;
|
||||
|
||||
field.config = {
|
||||
...field.config,
|
||||
@@ -202,7 +202,6 @@ export function LogsTable(props: Props) {
|
||||
panelState={props.panelState}
|
||||
absoluteRange={props.absoluteRange}
|
||||
logRows={props.logRows}
|
||||
rowIndex={cellProps.rowIndex}
|
||||
/>
|
||||
<span className={styles.firstColumnCell}>
|
||||
{cellProps.field.display?.(cellProps.value).text ?? String(cellProps.value)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useState, memo } from 'react';
|
||||
|
||||
import {
|
||||
AbsoluteTimeRange,
|
||||
@@ -13,7 +13,7 @@ import { t } from '@grafana/i18n';
|
||||
import { ClipboardButton, CustomCellRendererProps, IconButton, Modal, useTheme2 } from '@grafana/ui';
|
||||
import { getLogsPermalinkRange } from 'app/core/utils/shortLinks';
|
||||
import { getUrlStateFromPaneState } from 'app/features/explore/hooks/useStateSync';
|
||||
import { LogsFrame } from 'app/features/logs/logsFrame';
|
||||
import { LogsFrame, DATAPLANE_ID_NAME } from 'app/features/logs/logsFrame';
|
||||
import { getState } from 'app/store/store';
|
||||
|
||||
import { getExploreBaseUrl } from './utils/url';
|
||||
@@ -28,25 +28,20 @@ interface Props extends CustomCellRendererProps {
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export function LogsTableActionButtons(props: Props) {
|
||||
export const LogsTableActionButtons = memo((props: Props) => {
|
||||
const { exploreId, absoluteRange, logRows, rowIndex, panelState, displayedFields, logsFrame, frame } = props;
|
||||
|
||||
const theme = useTheme2();
|
||||
const [isInspecting, setIsInspecting] = useState(false);
|
||||
// Get logId from the table frame (frame), not the original logsFrame, because
|
||||
// the table frame is sorted/transformed and rowIndex refers to the table frame
|
||||
const idFieldName = logsFrame?.idField?.name ?? 'id';
|
||||
const idField = frame.fields.find((field) => field.name === idFieldName || field.name === 'id');
|
||||
const idFieldName = logsFrame?.idField?.name ?? DATAPLANE_ID_NAME;
|
||||
const idField = frame.fields.find((field) => field.name === idFieldName || field.name === DATAPLANE_ID_NAME);
|
||||
const logId = idField?.values[rowIndex];
|
||||
const getLineValue = () => {
|
||||
const bodyFieldName = logsFrame?.bodyField?.name;
|
||||
const bodyField = bodyFieldName
|
||||
? frame.fields.find((field) => field.name === bodyFieldName)
|
||||
: frame.fields.find((field) => field.type === 'string');
|
||||
return bodyField?.values[rowIndex];
|
||||
};
|
||||
|
||||
const lineValue = getLineValue();
|
||||
const getLineValue = () => {
|
||||
const logRowById = logRows?.find((row) => row.rowId === logId);
|
||||
return logRowById?.raw ?? '';
|
||||
};
|
||||
|
||||
const styles = getStyles(theme);
|
||||
|
||||
@@ -105,33 +100,29 @@ export function LogsTableActionButtons(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.iconWrapper}>
|
||||
<div className={styles.inspect}>
|
||||
<IconButton
|
||||
className={styles.inspectButton}
|
||||
tooltip={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
|
||||
variant="secondary"
|
||||
aria-label={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
|
||||
tooltipPlacement="top"
|
||||
size="md"
|
||||
name="eye"
|
||||
onClick={handleViewClick}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.inspect}>
|
||||
<ClipboardButton
|
||||
className={styles.clipboardButton}
|
||||
icon="share-alt"
|
||||
variant="secondary"
|
||||
fill="text"
|
||||
size="md"
|
||||
tooltip={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
|
||||
tooltipPlacement="top"
|
||||
tabIndex={0}
|
||||
aria-label={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
|
||||
getText={getText}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
className={styles.icon}
|
||||
tooltip={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
|
||||
variant="secondary"
|
||||
aria-label={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
|
||||
tooltipPlacement="top"
|
||||
size="md"
|
||||
name="eye"
|
||||
onClick={handleViewClick}
|
||||
tabIndex={0}
|
||||
/>
|
||||
<ClipboardButton
|
||||
className={styles.icon}
|
||||
icon="share-alt"
|
||||
variant="secondary"
|
||||
fill="text"
|
||||
size="md"
|
||||
tooltip={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
|
||||
tooltipPlacement="top"
|
||||
tabIndex={0}
|
||||
aria-label={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
|
||||
getText={getText}
|
||||
/>
|
||||
</div>
|
||||
{isInspecting && (
|
||||
<Modal
|
||||
@@ -139,9 +130,9 @@ export function LogsTableActionButtons(props: Props) {
|
||||
isOpen={true}
|
||||
title={t('explore.logs-table.action-buttons.inspect-value', 'Inspect value')}
|
||||
>
|
||||
<pre>{lineValue}</pre>
|
||||
<pre>{getLineValue()}</pre>
|
||||
<Modal.ButtonRow>
|
||||
<ClipboardButton icon="copy" getText={() => lineValue}>
|
||||
<ClipboardButton icon="copy" getText={() => getLineValue()}>
|
||||
{t('explore.logs-table.action-buttons.copy-to-clipboard', 'Copy to Clipboard')}
|
||||
</ClipboardButton>
|
||||
</Modal.ButtonRow>
|
||||
@@ -149,15 +140,11 @@ export function LogsTableActionButtons(props: Props) {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
clipboardButton: css({
|
||||
height: '100%',
|
||||
lineHeight: '1',
|
||||
padding: 0,
|
||||
width: '20px',
|
||||
}),
|
||||
LogsTableActionButtons.displayName = 'LogsTableActionButtons';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
iconWrapper: css({
|
||||
background: theme.colors.background.secondary,
|
||||
boxShadow: theme.shadows.z2,
|
||||
@@ -166,25 +153,50 @@ export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
height: '35px',
|
||||
left: 0,
|
||||
top: 0,
|
||||
padding: `0 ${theme.spacing(0.5)}`,
|
||||
padding: 0,
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
alignItems: 'center',
|
||||
// Fix switching icon direction when cell is numeric (rtl)
|
||||
direction: 'ltr',
|
||||
}),
|
||||
inspect: css({
|
||||
'& button svg': {
|
||||
marginRight: 'auto',
|
||||
icon: css({
|
||||
gap: 0,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
width: '28px',
|
||||
height: '32px',
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
|
||||
'&:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
width: 24,
|
||||
height: 24,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
margin: 'auto',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
zIndex: -1,
|
||||
opacity: 0,
|
||||
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||
transitionDuration: '0.2s',
|
||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transitionProperty: 'opacity',
|
||||
},
|
||||
},
|
||||
'&:hover': {
|
||||
color: theme.colors.text.link,
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
'&:before': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
padding: '5px 3px',
|
||||
}),
|
||||
inspectButton: css({
|
||||
borderRadius: theme.shape.radius.default,
|
||||
display: 'inline-flex',
|
||||
margin: 0,
|
||||
overflow: 'hidden',
|
||||
verticalAlign: 'middle',
|
||||
}),
|
||||
});
|
||||
|
||||
+1
@@ -132,6 +132,7 @@ export default function SpanFlameGraph(props: SpanFlameGraphProps) {
|
||||
type: profilesDataSourceSettings.type,
|
||||
uid: profilesDataSourceSettings.uid,
|
||||
},
|
||||
includeExemplars: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ function getField(cache: FieldCache, name: string, fieldType: FieldType): FieldW
|
||||
const DATAPLANE_TIMESTAMP_NAME = 'timestamp';
|
||||
const DATAPLANE_BODY_NAME = 'body';
|
||||
const DATAPLANE_SEVERITY_NAME = 'severity';
|
||||
const DATAPLANE_ID_NAME = 'id';
|
||||
export const DATAPLANE_ID_NAME = 'id';
|
||||
const DATAPLANE_LABELS_NAME = 'labels';
|
||||
|
||||
// NOTE: this is a hot fn, we need to avoid allocating new objects here
|
||||
|
||||
+2
@@ -33,6 +33,7 @@ describe('QueryEditor', () => {
|
||||
refId: 'A',
|
||||
maxNodes: 1000,
|
||||
groupBy: [],
|
||||
includeExemplars: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -125,6 +126,7 @@ function setup(options: { props: Partial<Props> } = { props: {} }) {
|
||||
maxNodes: 1000,
|
||||
groupBy: [],
|
||||
limit: 42,
|
||||
includeExemplars: false,
|
||||
}}
|
||||
datasource={setupDs()}
|
||||
onChange={onChange}
|
||||
|
||||
+14
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
|
||||
import * as React from 'react';
|
||||
|
||||
import { CoreApp, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { useStyles2, RadioButtonGroup, MultiSelect, Input, InlineSwitch } from '@grafana/ui';
|
||||
|
||||
import { Query } from '../types';
|
||||
@@ -56,6 +57,9 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) {
|
||||
if (query.maxNodes) {
|
||||
collapsedInfo.push(`Max nodes: ${query.maxNodes}`);
|
||||
}
|
||||
if (query.includeExemplars) {
|
||||
collapsedInfo.push(`With exemplars`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0} direction="column">
|
||||
@@ -142,6 +146,16 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) {
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
{config.featureToggles.profilesExemplars && (
|
||||
<EditorField label={'Exemplars'} tooltip={<>Include profile exemplars in the time series.</>}>
|
||||
<InlineSwitch
|
||||
value={query.includeExemplars || false}
|
||||
onChange={(event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
onQueryChange({ ...query, includeExemplars: event.currentTarget.checked });
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
</div>
|
||||
</QueryOptionGroup>
|
||||
</Stack>
|
||||
|
||||
@@ -42,8 +42,10 @@ composableKinds: DataQuery: {
|
||||
// Sets the maximum number of nodes in the flamegraph.
|
||||
maxNodes?: int64
|
||||
#PyroscopeQueryType: "metrics" | "profile" | *"both" @cuetsy(kind="type")
|
||||
// If set to true, the response will contain annotations
|
||||
annotations?: bool
|
||||
// If set to true, the response will contain annotations
|
||||
annotations?: bool
|
||||
// If set to true, exemplars will be requested
|
||||
includeExemplars: bool | *false
|
||||
}
|
||||
}]
|
||||
lenses: []
|
||||
|
||||
@@ -23,6 +23,10 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
|
||||
* Allows to group the results.
|
||||
*/
|
||||
groupBy: Array<string>;
|
||||
/**
|
||||
* If set to true, exemplars will be requested
|
||||
*/
|
||||
includeExemplars: boolean;
|
||||
/**
|
||||
* Specifies the query label selectors.
|
||||
*/
|
||||
@@ -47,6 +51,7 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
|
||||
|
||||
export const defaultGrafanaPyroscopeDataQuery: Partial<GrafanaPyroscopeDataQuery> = {
|
||||
groupBy: [],
|
||||
includeExemplars: false,
|
||||
labelSelector: '{}',
|
||||
spanSelector: [],
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ describe('Pyroscope data source', () => {
|
||||
queryType: 'both',
|
||||
profileTypeId: '',
|
||||
groupBy: [''],
|
||||
includeExemplars: false,
|
||||
},
|
||||
]);
|
||||
expect(queries).toMatchObject([
|
||||
@@ -118,6 +119,7 @@ describe('normalizeQuery', () => {
|
||||
queryType: 'metrics',
|
||||
profileTypeId: 'cpu',
|
||||
refId: '',
|
||||
includeExemplars: false,
|
||||
});
|
||||
expect(normalized).toMatchObject({
|
||||
labelSelector: '{app="myapp"}',
|
||||
@@ -145,6 +147,7 @@ const defaultQuery = (query: Partial<Query>): Query => {
|
||||
labelSelector: '',
|
||||
profileTypeId: '',
|
||||
queryType: defaultPyroscopeQueryType,
|
||||
includeExemplars: false,
|
||||
...query,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -129,6 +129,7 @@ export class PyroscopeDataSource extends DataSourceWithBackend<Query, PyroscopeD
|
||||
queryType: 'both',
|
||||
profileTypeId: '',
|
||||
groupBy: [],
|
||||
includeExemplars: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -762,6 +762,19 @@ describe('Tempo service graph view', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should escape span with multi line content correctly', () => {
|
||||
const spanContent = [
|
||||
`
|
||||
SELECT * from "my_table"
|
||||
WHERE "data_enabled" = 1
|
||||
ORDER BY "name" ASC`,
|
||||
];
|
||||
let escaped = getEscapedRegexValues(getEscapedValues(spanContent));
|
||||
expect(escaped).toEqual([
|
||||
'\\n SELECT \\\\* from \\"my_table\\"\\n WHERE \\"data_enabled\\" = 1\\n ORDER BY \\"name\\" ASC',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should get field config correctly', () => {
|
||||
let datasourceUid = 's4Jvz8Qnk';
|
||||
let tempoDatasourceUid = 'EbPO1fYnz';
|
||||
|
||||
@@ -1168,7 +1168,7 @@ export function getEscapedRegexValues(values: string[]) {
|
||||
}
|
||||
|
||||
export function getEscapedValues(values: string[]) {
|
||||
return values.map((value: string) => value.replace(/["\\]/g, '\\$&'));
|
||||
return values.map((value: string) => value.replace(/["\\]/g, '\\$&').replace(/[\n]/g, '\\n'));
|
||||
}
|
||||
|
||||
export function getFieldConfig(
|
||||
|
||||
@@ -293,6 +293,7 @@ export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
|
||||
updated?: string;
|
||||
updated_by?: UpdatedBy | null;
|
||||
version?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// types for Grafana-managed recording and alerting rules
|
||||
|
||||
@@ -3791,7 +3791,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4454,6 +4453,7 @@
|
||||
},
|
||||
"no-properties-changed": "Žádné relevantní vlastnosti se nezměnily",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Datum",
|
||||
"updatedBy": "Aktualizoval uživatel",
|
||||
"version": "Verze"
|
||||
@@ -4912,7 +4912,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Hodnoty oddělené čárkou"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Filtr názvu",
|
||||
@@ -6010,6 +6011,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Vlastní možnosti",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Hodnoty oddělené čárkou",
|
||||
"selection-options": "Možnosti výběru"
|
||||
},
|
||||
@@ -6601,6 +6605,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Nástěnka byla uložena"
|
||||
},
|
||||
@@ -6624,6 +6633,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6683,8 +6693,11 @@
|
||||
"tooltip-show-usages": "Zobrazit použití"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Náhled hodnot",
|
||||
"show-more": "Zobrazit více"
|
||||
"show-more": "Zobrazit více",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_few": "",
|
||||
"preview-of-values_many": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "Keine relevanten Eigenschaften geändert",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Datum",
|
||||
"updatedBy": "Aktualisiert von",
|
||||
"version": "Version"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Werte werden durch Komma getrennt"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Namensfilter",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Benutzerdefinierte Optionen",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Werte werden durch Komma getrennt",
|
||||
"selection-options": "Auswahloptionen"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Dashboard gespeichert"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Nutzungen anzeigen"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Vorschau der Werte",
|
||||
"show-more": "Mehr anzeigen"
|
||||
"show-more": "Mehr anzeigen",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "Clear history",
|
||||
"empty": "Nothing viewed yet",
|
||||
"error": "Recently viewed dashboards couldn’t be loaded.",
|
||||
"retry": "Retry",
|
||||
"title": "Recently viewed"
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "No relevant properties changed",
|
||||
"table": {
|
||||
"notes": "Notes",
|
||||
"updated": "Date",
|
||||
"updatedBy": "Updated By",
|
||||
"version": "Version"
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "No se ha cambiado ninguna propiedad relevante",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Fecha",
|
||||
"updatedBy": "Actualizada por",
|
||||
"version": "Versión"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Valores separados por coma"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Nombrar filtro",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Opciones personalizadas",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Valores separados por comas",
|
||||
"selection-options": "Opciones de selección"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Dashboard guardado"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Mostrar usos"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Vista previa de los valores",
|
||||
"show-more": "Mostrar más"
|
||||
"show-more": "Mostrar más",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "Aucune propriété pertinente n’a été modifiée",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Date",
|
||||
"updatedBy": "Mis à jour par",
|
||||
"version": "Version"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Valeurs séparées par une virgule"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Nom du filtre",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Personnaliser les options",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Valeurs séparées par des virgules",
|
||||
"selection-options": "Options de sélection"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Tableau de bord enregistré"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Afficher les usages"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Aperçu des valeurs",
|
||||
"show-more": "Afficher plus"
|
||||
"show-more": "Afficher plus",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "Nem változtak meg a releváns tulajdonságok",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Dátum",
|
||||
"updatedBy": "Frissítette:",
|
||||
"version": "Verzió"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Értékek vesszővel elválasztva"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Névszűrő",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Egyéni opciók",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Értékek vesszővel elválasztva",
|
||||
"selection-options": "Kijelölés beállításai"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Irányítópult elmentve"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Használatok megjelenítése"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Értékek előnézete",
|
||||
"show-more": "Több megjelenítése"
|
||||
"show-more": "Több megjelenítése",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3743,7 +3743,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4397,6 +4396,7 @@
|
||||
},
|
||||
"no-properties-changed": "Tidak ada properti yang relevan yang diubah",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Tanggal",
|
||||
"updatedBy": "Diperbarui Oleh",
|
||||
"version": "Versi"
|
||||
@@ -4855,7 +4855,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Nilai dipisahkan dengan koma"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Filter nama",
|
||||
@@ -5947,6 +5948,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Opsi kustom",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Nilai dipisahkan dengan koma",
|
||||
"selection-options": "Opsi pemilihan"
|
||||
},
|
||||
@@ -6532,6 +6536,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Dasbor disimpan"
|
||||
},
|
||||
@@ -6555,6 +6564,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6614,8 +6624,8 @@
|
||||
"tooltip-show-usages": "Tampilkan penggunaan"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Pratinjau nilai",
|
||||
"show-more": "Tampilkan lebih banyak"
|
||||
"show-more": "Tampilkan lebih banyak",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "Nessuna proprietà rilevante modificata",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Data",
|
||||
"updatedBy": "Aggiornato da",
|
||||
"version": "Versione"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Valori separati da virgola"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Filtro nome",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Opzioni personalizzate",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Valori separati da virgola",
|
||||
"selection-options": "Seleziona opzioni"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Dashboard salvata"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Mostra utilizzi"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Anteprima dei valori",
|
||||
"show-more": "Mostra di più"
|
||||
"show-more": "Mostra di più",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3743,7 +3743,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4397,6 +4396,7 @@
|
||||
},
|
||||
"no-properties-changed": "関連するプロパティは変更されていません",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "日付",
|
||||
"updatedBy": "更新者",
|
||||
"version": "バージョン"
|
||||
@@ -4855,7 +4855,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "カンマで区切った値"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "名前フィルター",
|
||||
@@ -5947,6 +5948,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "カスタムオプション",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "カンマ区切りの値",
|
||||
"selection-options": "選択オプション"
|
||||
},
|
||||
@@ -6532,6 +6536,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "ダッシュボードが保存されました"
|
||||
},
|
||||
@@ -6555,6 +6564,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6614,8 +6624,8 @@
|
||||
"tooltip-show-usages": "使用状況を表示"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "値のプレビュー",
|
||||
"show-more": "さらに表示"
|
||||
"show-more": "さらに表示",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3743,7 +3743,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4397,6 +4396,7 @@
|
||||
},
|
||||
"no-properties-changed": "변경된 관련 속성 없음",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "날짜",
|
||||
"updatedBy": "업데이트한 사용자",
|
||||
"version": "버전"
|
||||
@@ -4855,7 +4855,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "쉼표로 구분된 값"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "이름 필터",
|
||||
@@ -5947,6 +5948,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "사용자 지정 옵션",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "쉼표로 구분된 값",
|
||||
"selection-options": "선택 옵션"
|
||||
},
|
||||
@@ -6532,6 +6536,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "대시보드가 저장되었습니다"
|
||||
},
|
||||
@@ -6555,6 +6564,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6614,8 +6624,8 @@
|
||||
"tooltip-show-usages": "사용처 표시"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "값 미리 보기",
|
||||
"show-more": "더 보기"
|
||||
"show-more": "더 보기",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user