merge main
This commit is contained in:
+2
-6
@@ -3709,7 +3709,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "7"]
|
||||
],
|
||||
"public/app/features/dashboard/api/v0.ts:5381": [
|
||||
"public/app/features/dashboard/api/v1.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx:5381": [
|
||||
@@ -5641,15 +5641,11 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "\'@grafana/data/src/types/pluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"]
|
||||
],
|
||||
"public/app/features/plugins/extensions/usePluginComponents.tsx:5381": [
|
||||
[0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/plugins/extensions/usePluginFunctions.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/plugins/extensions/usePluginLinks.tsx:5381": [
|
||||
[0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"]
|
||||
],
|
||||
"public/app/features/plugins/extensions/validators.ts:5381": [
|
||||
[0, 0, 0, "\'@grafana/data/src/types/pluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"]
|
||||
],
|
||||
|
||||
+1
-46
@@ -4119,51 +4119,6 @@ volumes:
|
||||
path: /var/run/docker.sock
|
||||
name: docker
|
||||
---
|
||||
clone:
|
||||
retries: 3
|
||||
depends_on: []
|
||||
environment:
|
||||
EDITION: oss
|
||||
image_pull_secrets:
|
||||
- gcr
|
||||
- gar
|
||||
kind: pipeline
|
||||
name: release-whatsnew-checker
|
||||
node:
|
||||
type: no-parallel
|
||||
platform:
|
||||
arch: amd64
|
||||
os: linux
|
||||
services: []
|
||||
steps:
|
||||
- commands:
|
||||
- go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd
|
||||
depends_on: []
|
||||
environment:
|
||||
CGO_ENABLED: 0
|
||||
image: golang:1.23.5-alpine
|
||||
name: compile-build-cmd
|
||||
- commands:
|
||||
- ./bin/build whatsnew-checker
|
||||
depends_on:
|
||||
- compile-build-cmd
|
||||
image: golang:1.23.5-alpine
|
||||
name: whats-new-checker
|
||||
trigger:
|
||||
event:
|
||||
exclude:
|
||||
- promote
|
||||
ref:
|
||||
exclude:
|
||||
- refs/tags/*-cloud*
|
||||
include:
|
||||
- refs/tags/v*
|
||||
type: docker
|
||||
volumes:
|
||||
- host:
|
||||
path: /var/run/docker.sock
|
||||
name: docker
|
||||
---
|
||||
clone:
|
||||
retries: 3
|
||||
depends_on:
|
||||
@@ -5605,6 +5560,6 @@ kind: secret
|
||||
name: gcr_credentials
|
||||
---
|
||||
kind: signature
|
||||
hmac: 7a16cbedacf8a7381304abfde88319ce712a133e24c1ab304543c4c7ac318816
|
||||
hmac: 6e01278547a6f0803b7723e5f1e73bd94c572fa4805517232d3897717cee30f6
|
||||
|
||||
...
|
||||
|
||||
@@ -36,7 +36,7 @@ require (
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d // indirect
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
|
||||
@@ -73,8 +73,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/grafana-app-sdk v0.31.0 h1:/mFCcx+YqG8cWAi9hePDJQxIdtXDClDIDRgZwHkksFk=
|
||||
github.com/grafana/grafana-app-sdk v0.31.0/go.mod h1:Xw00NL7qpRLo5r3Gn48Bl1Xn2n4eUDI5pYf/wMufKWs=
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d h1:0WJ4uZ3gtbX7S5RGMarA+0TccmB7qEPhhNm34NcfBzs=
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d/go.mod h1:PNmbi49lVrv2b2I8pdu46dTs2728lKEbnVuQD8I5MnM=
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295 h1:bOKkAzmkCnK2EN0LGsHoq5HyfXMLKXrr8+q35Fh+XQI=
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295/go.mod h1:1iWqntBW91TsiyqneHrqTFV12vd1Tei+f5VY2ovkNAo=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY=
|
||||
|
||||
-2
@@ -19,8 +19,6 @@ You cannot configure LBAC rules for Grafana-provisioned data sources from the UI
|
||||
|
||||
## Before you begin
|
||||
|
||||
To be able to use LBAC for data sources rules, you need to enable the feature toggle `teamHttpHeaders` on your Grafana instance. Contact support to enable the feature toggle for you.
|
||||
|
||||
- Be sure that you have the permission setup to create a Loki tenant in Grafana Cloud
|
||||
- Be sure that you have admin data source permissions for Grafana.
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ To use profiling data, you should:
|
||||
|
||||
- [Configure your application to send profiles](/docs/pyroscope/<PYROSCOPE_VERSION>/configure-client/)
|
||||
- [Configure the Grafana Pyroscope data source](./configure-pyroscope-data-source/).
|
||||
- [View and query profiling data using Explore Profiles or the query editor ](./query-profile-data/)
|
||||
- [View and query profiling data using Profiles Drilldown or the query editor ](./query-profile-data/)
|
||||
|
||||
## Continuous profiling
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ If you are running a self-managed Grafana instance or need to configure an addit
|
||||
The connection URL for a self-managed Pyroscope backend depends on how Pyroscope is deployed.
|
||||
Refer to the steps under [Query profiles in Grafana](https://grafana.com/docs/pyroscope/<PYROSCOPE_VERSION>/deploy-kubernetes/helm/#query-profiles-in-grafana) for more information on how to configure the data source.
|
||||
|
||||
If you plan to use the [Explore Profiles](ref:explore-profiles) application and you are running a self-managed Pyroscope backend in microservices mode, the data source connection URL should point to a gateway or proxy that routes requests to the corresponding Pyroscope service.
|
||||
If you plan to use the [Profiles Drilldown](ref:explore-profiles) application and you are running a self-managed Pyroscope backend in microservices mode, the data source connection URL should point to a gateway or proxy that routes requests to the corresponding Pyroscope service.
|
||||
Refer to the [Helm ingress configuration](https://github.com/grafana/pyroscope/blob/main/operations/pyroscope/helm/pyroscope/templates/ingress.yaml) for specific routing requirements.
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Explore your profiling data using Explore Profiles or the Pyroscope query editor.
|
||||
description: Explore your profiling data using Profiles Drilldown or the Pyroscope query editor.
|
||||
keywords:
|
||||
- query
|
||||
- profiling
|
||||
@@ -54,22 +54,22 @@ https://grafana.com/docs/grafana-cloud/visualizations/panels-visualizations/visu
|
||||
|
||||
# Query profile data
|
||||
|
||||
You can query your profile data using the open source Grafana Explore Profiles app or the built-in Grafana Pyroscope data source query editor.
|
||||
You can query your profile data using the open source Grafana Profiles Drilldown app or the built-in Grafana Pyroscope data source query editor.
|
||||
|
||||
- Explore Profiles provides a queryless experience for inspecting your profiling data with opinionated workflows to assist your investigation.
|
||||
- Profiles Drilldown provides a queryless experience for inspecting your profiling data with opinionated workflows to assist your investigation.
|
||||
- Data source query editor provides complete control over your data exploration and is recommended for experienced users.
|
||||
|
||||
In addition, you can embed flame graph panels in Grafana dashboards.
|
||||
Refer to the [Flame graph panel](ref:flame-graph-panel) documentation for details.
|
||||
|
||||
## Explore Profiles
|
||||
## Profiles Drilldown
|
||||
|
||||
[Explore Profiles](ref:explore-profiles) is a native Grafana application designed to integrate seamlessly with Pyroscope, the open source continuous profiling platform, providing a smooth, queryless experience for browsing and analyzing profiling data.
|
||||
[Grafana Profiles Drilldown](ref:explore-profiles) is a native Grafana application designed to integrate seamlessly with Pyroscope, the open source continuous profiling platform, providing a smooth, queryless experience for browsing and analyzing profiling data.
|
||||
|
||||
You can use Explore Profiles in Grafana Cloud or in your own Grafana instance.
|
||||
For more information, refer to [Access or install Explore Profiles](ref:explore-profiles-install).
|
||||
You can use Profiles Drilldown in Grafana Cloud or in your own Grafana instance.
|
||||
For more information, refer to [Access or install Profiles Drilldown](ref:explore-profiles-install).
|
||||
|
||||

|
||||

|
||||
|
||||
### Use cases
|
||||
|
||||
@@ -80,10 +80,10 @@ The main use cases are the following:
|
||||
- Proactive: Cutting costs, addressing latency issues, or optimizing memory usage for applications
|
||||
- Reactive: Resolving incidents with line-level accuracy or debugging active latency/memory issues
|
||||
|
||||
Explore Profiles provides an intuitive interface to specifically support proactivee and reactive use cases.
|
||||
Profiles Drilldown provides an intuitive interface to specifically support proactivee and reactive use cases.
|
||||
You get a holistic view of all of your services and how they're functioning, but also the ability to drill down for more targeted root cause analysis.
|
||||
|
||||
Explore Profiles offers a convenient platform to analyze profiles and get insights that are impossible to get from using other traditional signals like logs, metrics, or tracing.
|
||||
Profiles Drilldown offers a convenient platform to analyze profiles and get insights that are impossible to get from using other traditional signals like logs, metrics, or tracing.
|
||||
|
||||
{{< youtube id="x9aPw_CbIQc" >}}
|
||||
|
||||
@@ -93,7 +93,7 @@ Explore Profiles offers a convenient platform to analyze profiles and get insigh
|
||||
|
||||
The Pyroscope data source query editor gives you access to a profile type selector, a label selector, and collapsible options.
|
||||
|
||||
Like Explore Profiles, the query editor also provides a flame graph to visualize data.
|
||||
Like Profiles Drilldown, the query editor also provides a flame graph to visualize data.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ The queries use [TraceQL](/docs/tempo/latest/traceql), the query language design
|
||||
For general documentation on querying data sources in Grafana, refer to [Query and transform data](ref:query-transform-data).
|
||||
|
||||
{{< admonition type="tip" >}}
|
||||
Don't know TraceQL? Try [Explore Traces](ref:explore-traces-app), an intuitive, queryless app that lets you explore your tracing data using RED metrics.
|
||||
Don't know TraceQL? Try [Grafana Traces Drilldown](ref:explore-traces-app), an intuitive, queryless app that lets you explore your tracing data using RED metrics.
|
||||
{{< /admonition >}}
|
||||
|
||||
## Before you begin
|
||||
|
||||
@@ -8,27 +8,27 @@ title: Simplified exploration
|
||||
menuTitle: Simplified exploration
|
||||
weight: 100
|
||||
hero:
|
||||
title: Simplified exploration with the Explore apps
|
||||
title: Simplified exploration with the Drilldown apps
|
||||
level: 1
|
||||
width: 100
|
||||
height: 100
|
||||
description: Use Explore Profiles to investigate and identify issues using profiling data.
|
||||
description: Use the Drilldown apps to investigate and identify issues using telemetry data.
|
||||
cards:
|
||||
title_class: pt-0 lh-1
|
||||
items:
|
||||
- title: Explore Metrics
|
||||
- title: Grafana Metrics Drilldown
|
||||
href: ./metrics/
|
||||
description: Quickly find related metrics with a few clicks, without needing to write PromQL queries to retrieve metrics.
|
||||
height: 24
|
||||
- title: Explore Logs
|
||||
- title: Grafana Logs Drilldown
|
||||
href: ./logs/
|
||||
description: Visualize log volumes to easily detect anomalies or significant changes over time, without needing to compose LogQL queries.
|
||||
height: 24
|
||||
- title: Explore Traces
|
||||
- title: Grafana Traces Drilldown
|
||||
href: ./traces/
|
||||
description: Use Rate, Errors, and Duration (RED) metrics derived from traces to investigate and understand errors and latency issues within complex distributed systems.
|
||||
height: 24
|
||||
- title: Explore Profiles
|
||||
- title: Grafana Profiles Drilldown
|
||||
href: ./profiles/
|
||||
description: View and analyze high-level service performance, identify problem processes for optimization, and diagnose issues to determine root causes.
|
||||
height: 24
|
||||
@@ -36,14 +36,14 @@ cards:
|
||||
|
||||
# Simplified exploration
|
||||
|
||||
The Grafana Explore apps are designed for effortless data exploration through intuitive, queryless interactions.
|
||||
The Grafana Drilldown apps are designed for effortless data exploration through intuitive, queryless interactions.
|
||||
|
||||
Easily explore telemetry signals with these specialized tools, tailored specifically for the Grafana databases to provide quick and accurate insights.
|
||||
|
||||
To learn more, read:
|
||||
|
||||
- [From multi-line queries to no-code investigations: meeting Grafana users where they are](https://grafana.com/blog/2024/10/22/from-multi-line-queries-to-no-code-investigations-meeting-grafana-users-where-they-are/)
|
||||
- [A queryless experience for exploring metrics, logs, traces, and profiles: Introducing the Explore apps suite for Grafana](https://grafana.com/blog/2024/09/24/queryless-metrics-logs-traces-profiles/).
|
||||
- [A queryless experience for exploring metrics, logs, traces, and profiles: Introducing the Drilldown apps suite for Grafana](https://grafana.com/blog/2024/09/24/queryless-metrics-logs-traces-profiles/).
|
||||
|
||||
{{< youtube id="MSHeWWsHaIA" >}}
|
||||
|
||||
|
||||
@@ -4,19 +4,19 @@ labels:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
title: Explore Metrics
|
||||
title: Metrics Drilldown
|
||||
aliases:
|
||||
- ../explore-metrics/ # /docs/grafana/latest/explore/explore-metrics/
|
||||
canonical: https://grafana.com/docs/grafana/latest/explore/simplified-exploration/metrics/
|
||||
description: Explore Metrics lets you browse Prometheus-compatible metrics using an intuitive, queryless experience.
|
||||
description: Grafana Metrics Drilldown lets you browse Prometheus-compatible metrics using an intuitive, queryless experience.
|
||||
weight: 200
|
||||
---
|
||||
|
||||
# Grafana Explore Metrics
|
||||
# Grafana Metrics Drilldown
|
||||
|
||||
Grafana Explore Metrics is a query-less experience for browsing **Prometheus-compatible** metrics. Quickly find related metrics with just a few simple clicks, without needing to write PromQL queries to retrieve metrics.
|
||||
Grafana Metrics Drilldown is a query-less experience for browsing **Prometheus-compatible** metrics. Quickly find related metrics with just a few simple clicks, without needing to write PromQL queries to retrieve metrics.
|
||||
|
||||
With Explore Metrics, you can:
|
||||
With Grafana Metrics Drilldown, you can:
|
||||
|
||||
- Easily segment metrics based on their labels, so you can immediately spot anomalies and identify issues.
|
||||
- Automatically display the optimal visualization for each metric type (gauge vs. counter, for example) without manual setup.
|
||||
@@ -25,26 +25,25 @@ With Explore Metrics, you can:
|
||||
- View a history of user steps when navigating through metrics and their filters.
|
||||
- Seamlessly pivot to related telemetry, including log data.
|
||||
|
||||
{{< docs/play title="Explore Metrics" url="https://play.grafana.org/explore/metrics/trail?from=now-1h&to=now&var-ds=grafanacloud-demoinfra-prom&var-filters=&refresh=&metricPrefix=all" >}}
|
||||
{{< docs/play title="Grafana Metrics Drilldown" url="https://play.grafana.org/explore/metrics/trail?from=now-1h&to=now&var-ds=grafanacloud-demoinfra-prom&var-filters=&refresh=&metricPrefix=all" >}}
|
||||
|
||||
You can access Explore Metrics either as a standalone experience or as part of Grafana dashboards.
|
||||
You can access Grafana Metrics Drilldown either as a standalone experience or as part of Grafana dashboards.
|
||||
|
||||
## Standalone experience
|
||||
|
||||
To access Explore Metrics as a standalone experience:
|
||||
To access Grafana Metrics Drilldown as a standalone experience:
|
||||
|
||||
1. Click the arrow next to **Explore** in the Grafana left-side menu and click **Metrics**. You are taken to an overview page that shows recent metrics, bookmarks, and the option to select a new metric exploration.
|
||||
1. To get started with a new exploration, click **+ New metric exploration**.
|
||||
1. Click the arrow next to **Drilldown** in the Grafana left-side menu and click **Metrics**. You are taken to an overview page that shows recent metrics, bookmarks, and the option to select a new metric exploration.
|
||||
1. To get started with a new exploration, click **Let's start!**.
|
||||
1. Select **Prometheus** or any Prometheus-compatible data source available in the drop-down menu under **Data source**.
|
||||
1. Click **+ Add label** to select a label-value pair from the drop-down menu. You can add multiple label-value pairs. A label type will appear above the selected label with a drop-down list of options from which to choose. For example, if you select the label `container` a drop-down list of available containers appears.
|
||||
1. Click **+ Add label** to select a label-value pair from the drop-down menu. You can add multiple label-value pairs. A label type appears above the selected label with a drop-down list of options from which to choose. For example, if you select the label `container` a drop-down list of available containers appears.
|
||||
1. You can also search for metrics using keywords under **Search metrics** in the search bar.
|
||||
1. Use the time picker to select a date and time range from the drop-down menu or use an absolute time range.
|
||||
1. Click the down arrow next to the **Refresh** icon to set a refresh rate from the drop-down menu. The default is `Off`.
|
||||
1. Click the **Settings** icon and toggle **Always keep selected metric graph in-view** to keep your main graph always in view on the Breakdown drill-down tab.
|
||||
|
||||
The **History** button in the upper left corner tracks every step navigating through metric exploration.
|
||||
|
||||

|
||||

|
||||
|
||||
### Metrics exploration
|
||||
|
||||
@@ -54,19 +53,19 @@ To further explore a metric, click **Select** in the upper right corner of the m
|
||||
|
||||
- The **Overview** tab provides a description for each metric, as well as the metric `type` and `unit` associated with the metric. It also provides a list of labels associated with the metric. Click on any label to view drill-down visualizations.
|
||||
- The **Breakdown** tab depicts time series visualizations for each of the label-value pairs for the selected metric. You can further drill down on each label and click **Add to filter** to add the label/value pair into your filters. You can also change the **View** from grid to rows.
|
||||
- The **Related metrics** tab depicts related metrics with relevant key words. You can repeat the drill down process for any related metric. Toggle **SHow previews** to preview visualizations.
|
||||
- The **Related metrics** tab depicts related metrics with relevant key words. You can repeat the drill down process for any related metric. Toggle **Show previews** to preview visualizations.
|
||||
|
||||
Once you have gathered your metrics exploration data you can:
|
||||
After you have gathered your metrics exploration data you can:
|
||||
|
||||
- Click the **Explore** icon on the right side to open the graph in Explore, where you can modify the query or add the graph to a dashboard or incident.
|
||||
- Click the **Share** icon on the right side to copy the metric drill down URL to the clipboard so it can be shared.
|
||||
- Click the **Open in Explore** icon on the right side to open the graph in Explore, where you can modify the query or add the graph to a dashboard or incident.
|
||||
- Click the **Copy URL** icon on the right side to copy the metric drill down URL to the clipboard so it can be shared.
|
||||
- Click the **Star** icon on the right side to bookmark and save the metrics exploration.
|
||||
|
||||
## Dashboard experience
|
||||
|
||||
To access Explore Metrics via a dashboard:
|
||||
To access Grafana Metrics Drilldown via a dashboard:
|
||||
|
||||
1. Navigate to your dashboard.
|
||||
1. Select a time series panel.
|
||||
1. Click the panel menu in the upper right and select **Explore Metrics**. If there are multiple metrics, click on the one you want to explore.
|
||||
1. You will see a slide out drawer with the Metrics Experience, starting with the drill down. You can access the standalone experience by clicking **Open** in the upper right.
|
||||
1. Click the panel menu in the upper right and select **Metrics Drilldown**. If there are multiple metrics, click on the one you want to explore.
|
||||
1. You see a slide out drawer with the Metrics Experience, starting with the drill down. You can access the standalone experience by clicking **Open** in the upper right.
|
||||
|
||||
@@ -56,7 +56,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes |
|
||||
| `ssoSettingsApi` | Enables the SSO settings API and the OAuth configuration UIs in Grafana | Yes |
|
||||
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes |
|
||||
| `exploreMetrics` | Enables the new Explore Metrics core app | Yes |
|
||||
| `exploreMetrics` | Enables the new Grafana Metrics Drilldown core app | Yes |
|
||||
| `alertingSimplifiedRouting` | Enables users to easily configure alert notifications by specifying a contact point directly when editing or creating an alert rule | Yes |
|
||||
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes |
|
||||
| `lokiQueryHints` | Enables query hints for Loki | Yes |
|
||||
@@ -200,9 +200,9 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `dataplaneAggregator` | Enable grafana dataplane aggregator |
|
||||
| `lokiSendDashboardPanelNames` | Send dashboard and panel names to Loki when querying |
|
||||
| `alertingPrometheusRulesPrimary` | Uses Prometheus rules as the primary source of truth for ruler-enabled data sources |
|
||||
| `exploreLogsShardSplitting` | Used in Explore Logs to split queries into multiple queries based on the number of shards |
|
||||
| `exploreLogsAggregatedMetrics` | Used in Explore Logs to query by aggregated metrics |
|
||||
| `exploreLogsLimitedTimeRange` | Used in Explore Logs to limit the time range |
|
||||
| `exploreLogsShardSplitting` | Used in Logs Drilldown to split queries into multiple queries based on the number of shards |
|
||||
| `exploreLogsAggregatedMetrics` | Used in Logs Drilldown to query by aggregated metrics |
|
||||
| `exploreLogsLimitedTimeRange` | Used in Logs Drilldown to limit the time range |
|
||||
| `homeSetupGuide` | Used in Home for users who want to return to the onboarding flow or quickly find popular config pages |
|
||||
| `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time |
|
||||
| `rolePickerDrawer` | Enables the new role picker drawer design |
|
||||
|
||||
+3
-1
@@ -15,7 +15,9 @@ Passwordless authentication lets Grafana users authenticate with a magic link or
|
||||
|
||||
## Enable passwordless authentication
|
||||
|
||||
{{< docs/experimental product="passwordless authentication" featureFlag="passwordlessMagicLinkAuthentication" >}}
|
||||
{{% admonition type="note" %}}
|
||||
Passwordless authentication is an experimental feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `passwordlessMagicLinkAuthentication` feature toggle in Grafana to use this feature.
|
||||
{{% /admonition %}}
|
||||
|
||||
To enable passwordless authentication, use the following configuration:
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ describe('Loki Query Editor', () => {
|
||||
// One row with two cells
|
||||
cy.get('[role="cell"]').should('have.length', 2);
|
||||
|
||||
cy.contains('label', 'targetLabelName').scrollIntoView();
|
||||
cy.contains('label', 'targetLabelName').should('be.visible');
|
||||
cy.contains('label', 'targetLabelName').click();
|
||||
cy.contains('label', 'targetLabelName').within(() => {
|
||||
|
||||
@@ -74,7 +74,7 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664 // @grafana/alerting-backend
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 // @grafana/alerting-backend
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 // @grafana/identity-access-team
|
||||
github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 // @grafana/identity-access-team
|
||||
github.com/grafana/dashboard-linter v0.0.0-20241106223805-1e7999311752 // @grafana/grafana-app-platform-squad
|
||||
@@ -205,21 +205,21 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173 // @grafana/plugins-platform-backend
|
||||
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d // @grafana/alerting-backend
|
||||
github.com/grafana/grafana/apps/investigations v0.0.0-20250217190546-9df6412e9255 // @fcjack @matryer
|
||||
github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250220163425-b4c4b9abbdc8 // @grafana/plugins-platform-backend
|
||||
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250220163425-b4c4b9abbdc8 // @grafana/alerting-backend
|
||||
github.com/grafana/grafana/apps/investigations v0.0.0-20250220163425-b4c4b9abbdc8 // @fcjack @matryer
|
||||
github.com/grafana/grafana/apps/playlist v0.0.0-20250220164708-c8d4ff28a450 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295 // @grafana/grafana-app-platform-squad
|
||||
|
||||
// This needs to be here for other projects that import grafana/grafana
|
||||
// For local development grafana/grafana will always use the local files
|
||||
// Check go.work file for details
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.8 // @grafana/oss-big-tent
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-search-and-storage
|
||||
github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-search-and-storage
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250220154326-6e5de80ef295 // @grafana/grafana-search-and-storage
|
||||
github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250220154326-6e5de80ef295 // @grafana/grafana-search-and-storage
|
||||
)
|
||||
|
||||
require github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend
|
||||
@@ -375,7 +375,7 @@ require (
|
||||
github.com/grafana/loki/pkg/push v0.0.0-20231124142027-e52380921608 // indirect
|
||||
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
|
||||
github.com/grafana/sqlds/v4 v4.1.3 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/grafana-search-and-storage
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect
|
||||
github.com/hashicorp/consul/api v1.30.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
@@ -457,9 +457,9 @@ require (
|
||||
github.com/oklog/ulid/v2 v2.1.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||
github.com/opentracing-contrib/go-grpc v0.0.0-20210225150812-73cb765af46e // indirect
|
||||
github.com/opentracing-contrib/go-grpc v0.0.0-20210225150812-73cb765af46e // @grafana/grafana-search-and-storage
|
||||
github.com/opentracing-contrib/go-stdlib v1.0.0 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // @grafana/grafana-search-and-storage
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
|
||||
@@ -1528,8 +1528,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664 h1:avtd90OVg2wMHz5pUsTWypu4rEt5bn5H0kOLXa7xgqc=
|
||||
github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 h1:LGH+tVzHCDrR9hsltmkP4jmNRg5IreQw5CNFbJKlnts=
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 h1:NTMmow+74I3Jb033xhbRgWQS7A//5TDhiM4tl7bsVP4=
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7/go.mod h1:T3X4z0ejGfJOiOmZLFeKCRT/yxWJq/RtclAc/PHj/w4=
|
||||
github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 h1:EokLC5grHwLPs4tXW8T6E8187H1e5G9AP0QQ5B60HbA=
|
||||
@@ -1568,28 +1568,28 @@ github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79
|
||||
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.266.0 h1:YP+iEpXH3HRX9Xo4NHjsrJhN2W7uVTtkLNzMHYbmiLI=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.266.0/go.mod h1:bxkXrBQ4QSmOncsWdIOcpgP+M6wajQNMAPXlbWrqAWY=
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173 h1:uOM89HiWVVOTls0LrD4coHTckb2lA4U0sIJwCYdbhbw=
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA=
|
||||
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d h1:NRVOtiG1aUwOazBj9KM7X2o2shsM6TchqisezzoH1gw=
|
||||
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d/go.mod h1:AvleS6icyPmcBjihtx5jYEvdzLmHGBp66NuE0AMR57A=
|
||||
github.com/grafana/grafana/apps/investigations v0.0.0-20250217190546-9df6412e9255 h1:4bEH/v1cbODIie8rMAqrsUlXeWLu7ht9SXNIrdvMWmA=
|
||||
github.com/grafana/grafana/apps/investigations v0.0.0-20250217190546-9df6412e9255/go.mod h1:ygFcJP2McdSeMJVj/3YrKafZMc/lZBsp54HO51MtJYw=
|
||||
github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d h1:cJ+9jgHw8Z17rM+DCdxvyWxPqH9OB9fkPLbnWEO+QGg=
|
||||
github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d/go.mod h1:DjJe5osrW/BKrzN9hAAOSElNWutj1bcriExa7iDP7kA=
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d h1:aBD5kzsIAh50vjNqUkWK9mNpLGIBYAnKkWtUepGNAiQ=
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d/go.mod h1:1sq0guad+G4SUTlBgx7SXfhnzy7D86K/LcVOtiQCiMA=
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d h1:0WJ4uZ3gtbX7S5RGMarA+0TccmB7qEPhhNm34NcfBzs=
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d/go.mod h1:PNmbi49lVrv2b2I8pdu46dTs2728lKEbnVuQD8I5MnM=
|
||||
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d h1:vVaLOibSj5lDwCb+pa0fly9uods8nTq1NJPYrTuPAGE=
|
||||
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d/go.mod h1:srJ6H0DL7c9Cz4CmK0h4AOlC1sm8UDwn9jBZ+RHxBt4=
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250220163425-b4c4b9abbdc8 h1:mG/6nDlEBVxWlo2GQJVASzucw3ByPIBsec06XcPrjgQ=
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250220163425-b4c4b9abbdc8/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c=
|
||||
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250220163425-b4c4b9abbdc8 h1:w42GlvkmHG4nM/p1kb2nKmROVP+AHtL3qWEYMhnhCVM=
|
||||
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250220163425-b4c4b9abbdc8/go.mod h1:dHhFF484qs1cmdIShKCB3kl+tMJyc4yuwgTQ3Afz37o=
|
||||
github.com/grafana/grafana/apps/investigations v0.0.0-20250220163425-b4c4b9abbdc8 h1:jRcI2fE/u0tHfCmPLIt21M3DYOt3L6BmZcu/LeV2sRw=
|
||||
github.com/grafana/grafana/apps/investigations v0.0.0-20250220163425-b4c4b9abbdc8/go.mod h1:ygFcJP2McdSeMJVj/3YrKafZMc/lZBsp54HO51MtJYw=
|
||||
github.com/grafana/grafana/apps/playlist v0.0.0-20250220164708-c8d4ff28a450 h1:h3HsylGnuZiBgT5Q/N6bH+LazY3+nA98R76Xwcd0zXg=
|
||||
github.com/grafana/grafana/apps/playlist v0.0.0-20250220164708-c8d4ff28a450/go.mod h1:KKIsWpbv88Lwwcvdjon73zFL7vNJvuXLtsSoUjJErTw=
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 h1:9qOLpC21AmXZqZ6rUhrBWl2mVqS3CzV53pzw0BCuHt0=
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8/go.mod h1:deLQ/ywLvpVGbncRGUA4UDGt8a5Ei9sivOP+x6AQ2ko=
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295 h1:bOKkAzmkCnK2EN0LGsHoq5HyfXMLKXrr8+q35Fh+XQI=
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295/go.mod h1:1iWqntBW91TsiyqneHrqTFV12vd1Tei+f5VY2ovkNAo=
|
||||
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295 h1:ivbywO8ZnmzDDkn169qUb9REsCGYAA7H8W6VfqSmCEw=
|
||||
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295/go.mod h1:OugmKouuvgWeOI8Kghram2Pv8b/1SuXRR8/iW068uLk=
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0=
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.8/go.mod h1:U1ezG/MGaEPoThqsr3lymMPN5yIPdVTJnDZ+wcXT+ao=
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d h1:cYBjuhb3m5oC6Z00Kw8DdySFaNhwb38SMxx0oXnz5vQ=
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c=
|
||||
github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d h1:4w4x8g6xCSRj06er81atNSQVL0fTSwLekDYsRxpz+M0=
|
||||
github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d/go.mod h1:CXpwZ3Mkw6xVlGKc0SqUxqXCP3Uv182q6qAQnLaLxRg=
|
||||
github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250121113133-e747350fee2d h1:xzJi+ioYZpiGYjiox9eZYqu1zczNV2PBnG6Y5XK7Ar0=
|
||||
github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250121113133-e747350fee2d/go.mod h1:2jv9CnelxFIH0q4FBaKt8BqaJyjruLmGk/31TzgsrPo=
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450 h1:wSqgLKFwI7fyeqf3djRXGClBLb/UPjZ4XPm/UsKFDB0=
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450/go.mod h1:HGz9/wKeN6U48g4F8RxHzpsFxJAR9anVltZp07A86UA=
|
||||
github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250220154326-6e5de80ef295 h1:mDRND2VJaVzhIHrcAfOCV1X39QHWGcVByQkUeqBjpNs=
|
||||
github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250220154326-6e5de80ef295/go.mod h1:b4i60JgZZjH8haVc4Uw8ye0FzxX4r28Y0EvduqxBdWo=
|
||||
github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250220154326-6e5de80ef295 h1:AtOyCicDN4xqF2ZHCXaVBQj/BQpFtb/RfYD8KlwGkaY=
|
||||
github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250220154326-6e5de80ef295/go.mod h1:QAzEkmHuxP5RPdc9H+l/Nxl+VOUtV075/Daqf77Nhf0=
|
||||
github.com/grafana/grafana/pkg/util/xorm v0.0.1 h1:72QZjxWIWpSeOF8ob4aMV058kfgZyeetkAB8dmeti2o=
|
||||
github.com/grafana/grafana/pkg/util/xorm v0.0.1/go.mod h1:eNfbB9f2jM8o9RfwqwjY8SYm5tvowJ8Ly+iE4P9rXII=
|
||||
github.com/grafana/jsonparser v0.0.0-20240425183733-ea80629e1a32 h1:NznuPwItog+rwdVg8hAuGKP29ndRSzJAwhxKldkP8oQ=
|
||||
|
||||
+5
-2
@@ -587,8 +587,6 @@ github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9
|
||||
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/cel-go v0.22.0/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs0yC4s8=
|
||||
github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
|
||||
github.com/google/go-github/v64 v64.0.0 h1:4G61sozmY3eiPAjjoOHponXDBONm+utovTKbyUb2Qdg=
|
||||
github.com/google/go-github/v64 v64.0.0/go.mod h1:xB3vqMQNdHzilXBiO2I+M7iEFtHf+DP/omBOv6tQzVo=
|
||||
github.com/google/go-jsonnet v0.18.0 h1:/6pTy6g+Jh1a1I2UMoAODkqELFiVIdOxbNwv0DDzoOg=
|
||||
github.com/google/go-jsonnet v0.18.0/go.mod h1:C3fTzyVJDslXdiTqw/bTFk7vSGyCtH3MGRbDfvEwGd0=
|
||||
github.com/google/go-pkcs11 v0.3.0 h1:PVRnTgtArZ3QQqTGtbtjtnIkzl2iY2kt24yqbrf7td8=
|
||||
@@ -615,8 +613,11 @@ github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||
github.com/grafana/authlib/types v0.0.0-20250120144156-d6737a7dc8f5/go.mod h1:qYjSd1tmJiuVoSICp7Py9/zD54O9uQQA3wuM6Gg4DFM=
|
||||
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I=
|
||||
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw=
|
||||
github.com/grafana/cog v0.0.23 h1:/0CCJ24Z8XXM2DnboSd2FzoIswUroqIZzVr8oJWmMQs=
|
||||
github.com/grafana/cog v0.0.23/go.mod h1:jrS9indvWuDs60RHEZpLaAkmZdgyoLKMOEUT0jiB1t0=
|
||||
github.com/grafana/go-gelf/v2 v2.0.1 h1:BOChP0h/jLeD+7F9mL7tq10xVkDG15he3T1zHuQaWak=
|
||||
github.com/grafana/go-gelf/v2 v2.0.1/go.mod h1:lexHie0xzYGwCgiRGcvZ723bSNyNI8ZRD4s0CLobh90=
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c=
|
||||
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU=
|
||||
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU=
|
||||
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
|
||||
@@ -931,6 +932,8 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk=
|
||||
github.com/xdg/stringprep v1.0.0 h1:d9X0esnoa3dFsV0FG35rAT0RIhYFlPq7MiP+DW89La0=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
|
||||
@@ -87,4 +87,8 @@ grafana::codegen:run pkg
|
||||
grafana::codegen:run pkg/apimachinery
|
||||
grafana::codegen:run pkg/aggregator
|
||||
|
||||
if [ -d "pkg/extensions/apis" ]; then
|
||||
grafana::codegen:run pkg/extensions
|
||||
fi
|
||||
|
||||
echo "done."
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@
|
||||
"generate-apis": "yarn process-specs && rtk-query-codegen-openapi ./scripts/generate-rtk-apis.ts"
|
||||
},
|
||||
"grafana": {
|
||||
"whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v11-5/",
|
||||
"whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v%[1]s-%[2]s/",
|
||||
"releaseNotesUrl": "https://grafana.com/docs/grafana/next/release-notes/"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -93,6 +93,7 @@ export const availableIconsIndex = {
|
||||
'document-layout-left': true,
|
||||
'download-alt': true,
|
||||
draggabledots: true,
|
||||
drilldown: true,
|
||||
edit: true,
|
||||
'ellipsis-v': true,
|
||||
enter: true,
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface NavLinkDTO {
|
||||
sortWeight?: number;
|
||||
hideFromTabs?: boolean;
|
||||
roundIcon?: boolean;
|
||||
isNew?: boolean;
|
||||
/**
|
||||
* This is true for some sections that have no children (but is still a section)
|
||||
**/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="49" viewBox="0 0 48 49" fill="currentColor">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.0044 5.21777C17.0044 3.56092 18.3476 2.21777 20.0044 2.21777H27.9148C29.5716 2.21777 30.9148 3.56092 30.9148 5.21777V9.12282L31.892 8.79869C32.8093 8.49441 33.7996 8.99139 34.1039 9.90874C34.4082 10.8261 33.9112 11.8164 32.9939 12.1207L30.9148 12.8103V19.2526L31.8924 18.9283C32.8098 18.6241 33.8001 19.121 34.1044 20.0384C34.4087 20.9557 33.9117 21.9461 32.9943 22.2503L30.9148 22.9401V28.9144C30.9148 29.4299 30.7819 29.9367 30.5291 30.3859L26.9845 36.6835C25.7386 38.9239 23.1565 38.741 21.8985 36.9123L17.5328 30.566C17.1887 30.0658 17.0044 29.4729 17.0044 28.8658V27.5542L16.0266 27.8785C15.1092 28.1828 14.1189 27.6858 13.8146 26.7685C13.5103 25.8511 14.0073 24.8608 14.9246 24.5565L17.0044 23.8667V17.4244L16.0261 17.7489C15.1087 18.0532 14.1184 17.5562 13.8141 16.6388C13.5098 15.7215 14.0068 14.7312 14.9242 14.4269L17.0044 13.7369V5.21777ZM21.0044 26.2274V28.3996C21.0044 28.5008 21.0351 28.5996 21.0925 28.683L23.8308 32.6636C24.0405 32.9684 24.497 32.9479 24.6785 32.6255L26.8505 28.7665C26.8926 28.6916 26.9148 28.6072 26.9148 28.5213V24.2669L21.0044 26.2274ZM26.9148 20.5794L21.0044 22.5399V16.0976L26.9148 14.1371V20.5794ZM26.9148 10.4496L21.0044 12.4101V6.71777C21.0044 6.44163 21.2283 6.21777 21.5044 6.21777H26.4148C26.6909 6.21777 26.9148 6.44163 26.9148 6.71777V10.4496Z"/>
|
||||
<path d="M42.3721 30.8303C42.8016 29.9645 42.4479 28.9144 41.5821 28.4849C40.7163 28.0554 39.6662 28.4091 39.2367 29.2749L35.448 36.9123C35.0185 37.7782 36.3722 38.8282 37.238 39.2577C38.1038 39.6872 38.1539 39.3335 38.5834 38.4677L42.3721 30.8303Z"/>
|
||||
<path d="M8.75767 29.296C8.32292 28.4328 7.27072 28.0855 6.40752 28.5202C5.54433 28.955 5.19701 30.0072 5.63176 30.8704L9.46537 38.482C9.90012 39.3452 10.9523 39.6925 11.8155 39.2577C12.6787 38.823 13.026 37.7708 12.5913 36.9076L8.75767 29.296Z"/>
|
||||
<path d="M10.3193 45.0314C10.3193 44.0649 11.1028 43.2814 12.0693 43.2814H36.1498C37.1163 43.2814 37.8998 44.0649 37.8998 45.0314C37.8998 45.9979 37.1163 46.7814 36.1498 46.7814H12.0693C11.1028 46.7814 10.3193 45.9979 10.3193 45.0314Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -86,7 +86,7 @@ export {
|
||||
} from './types';
|
||||
export { PrometheusVariableSupport } from './variables';
|
||||
|
||||
// For explore metrics
|
||||
// For Metrics Drilldown
|
||||
export { default as PromQlLanguageProvider } from './language_provider';
|
||||
export { getPrometheusTime } from './language_utils';
|
||||
export { isValidLegacyName, utf8Support, wrapUtf8Filters } from './utf8_support';
|
||||
|
||||
@@ -21,9 +21,6 @@ export {
|
||||
type GetPluginExtensionsResult,
|
||||
type UsePluginExtensions,
|
||||
type UsePluginExtensionsResult,
|
||||
type UsePluginComponentResult,
|
||||
type UsePluginFunctionsOptions,
|
||||
type UsePluginFunctionsResult,
|
||||
} from './pluginExtensions/getPluginExtensions';
|
||||
export {
|
||||
setPluginExtensionsHook,
|
||||
@@ -32,10 +29,29 @@ export {
|
||||
usePluginComponentExtensions,
|
||||
} from './pluginExtensions/usePluginExtensions';
|
||||
|
||||
export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent';
|
||||
export { setPluginComponentsHook, usePluginComponents } from './pluginExtensions/usePluginComponents';
|
||||
export { setPluginLinksHook, usePluginLinks } from './pluginExtensions/usePluginLinks';
|
||||
export { setPluginFunctionsHook, usePluginFunctions } from './pluginExtensions/usePluginFunctions';
|
||||
export {
|
||||
setPluginComponentHook,
|
||||
usePluginComponent,
|
||||
type UsePluginComponentResult,
|
||||
} from './pluginExtensions/usePluginComponent';
|
||||
export {
|
||||
setPluginComponentsHook,
|
||||
usePluginComponents,
|
||||
type UsePluginComponentsResult,
|
||||
type UsePluginComponentsOptions,
|
||||
} from './pluginExtensions/usePluginComponents';
|
||||
export {
|
||||
setPluginLinksHook,
|
||||
usePluginLinks,
|
||||
type UsePluginLinksOptions,
|
||||
type UsePluginLinksResult,
|
||||
} from './pluginExtensions/usePluginLinks';
|
||||
export {
|
||||
setPluginFunctionsHook,
|
||||
usePluginFunctions,
|
||||
type UsePluginFunctionsOptions,
|
||||
type UsePluginFunctionsResult,
|
||||
} from './pluginExtensions/usePluginFunctions';
|
||||
|
||||
export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils';
|
||||
export { setCurrentUser } from './user';
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
PluginExtension,
|
||||
PluginExtensionLink,
|
||||
PluginExtensionComponent,
|
||||
PluginExtensionFunction,
|
||||
PluginExtensionComponentMeta,
|
||||
} from '@grafana/data';
|
||||
import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } from '@grafana/data';
|
||||
|
||||
import { isPluginExtensionComponent, isPluginExtensionLink } from './utils';
|
||||
|
||||
@@ -23,11 +17,6 @@ export type GetPluginExtensionsOptions = {
|
||||
limitPerPlugin?: number;
|
||||
};
|
||||
|
||||
export type UsePluginComponentOptions = {
|
||||
extensionPointId: string;
|
||||
limitPerPlugin?: number;
|
||||
};
|
||||
|
||||
export type GetPluginExtensionsResult<T = PluginExtension> = {
|
||||
extensions: T[];
|
||||
};
|
||||
@@ -37,37 +26,6 @@ export type UsePluginExtensionsResult<T = PluginExtension> = {
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export type UsePluginComponentResult<Props = {}> = {
|
||||
component: React.ComponentType<Props> | undefined | null;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export type UsePluginComponentsResult<Props = {}> = {
|
||||
components: Array<React.ComponentType<Props> & { meta: PluginExtensionComponentMeta }>;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export type UsePluginLinksOptions = {
|
||||
extensionPointId: string;
|
||||
context?: object | Record<string | symbol, unknown>;
|
||||
limitPerPlugin?: number;
|
||||
};
|
||||
|
||||
export type UsePluginLinksResult = {
|
||||
isLoading: boolean;
|
||||
links: PluginExtensionLink[];
|
||||
};
|
||||
|
||||
export type UsePluginFunctionsOptions = {
|
||||
extensionPointId: string;
|
||||
limitPerPlugin?: number;
|
||||
};
|
||||
|
||||
export type UsePluginFunctionsResult<Signature> = {
|
||||
isLoading: boolean;
|
||||
functions: Array<PluginExtensionFunction<Signature>>;
|
||||
};
|
||||
|
||||
let singleton: GetPluginExtensions | undefined;
|
||||
|
||||
export function setPluginExtensionGetter(instance: GetPluginExtensions): void {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { UsePluginComponentResult } from './getPluginExtensions';
|
||||
export type UsePluginComponent<Props extends object = {}> = (componentId: string) => UsePluginComponentResult<Props>;
|
||||
|
||||
export type UsePluginComponent<Props extends object = {}> = (id: string) => UsePluginComponentResult<Props>;
|
||||
export type UsePluginComponentResult<Props = {}> = {
|
||||
component: React.ComponentType<Props> | undefined | null;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
let singleton: UsePluginComponent | undefined;
|
||||
|
||||
@@ -12,9 +15,9 @@ export function setPluginComponentHook(hook: UsePluginComponent): void {
|
||||
singleton = hook;
|
||||
}
|
||||
|
||||
export function usePluginComponent<Props extends object = {}>(id: string): UsePluginComponentResult<Props> {
|
||||
export function usePluginComponent<Props extends object = {}>(componentId: string): UsePluginComponentResult<Props> {
|
||||
if (!singleton) {
|
||||
throw new Error('setPluginComponentHook(options) can only be used after the Grafana instance has started.');
|
||||
}
|
||||
return singleton(id) as UsePluginComponentResult<Props>;
|
||||
return singleton(componentId) as UsePluginComponentResult<Props>;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { GetPluginExtensionsOptions, UsePluginComponentsResult } from './getPluginExtensions';
|
||||
import { PluginExtensionComponentMeta } from '@grafana/data';
|
||||
|
||||
export type UsePluginComponentsOptions = {
|
||||
extensionPointId: string;
|
||||
limitPerPlugin?: number;
|
||||
};
|
||||
|
||||
export type UsePluginComponentsResult<Props = {}> = {
|
||||
components: Array<React.ComponentType<Props> & { meta: PluginExtensionComponentMeta }>;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export type UsePluginComponents<Props extends object = {}> = (
|
||||
options: GetPluginExtensionsOptions
|
||||
options: UsePluginComponentsOptions
|
||||
) => UsePluginComponentsResult<Props>;
|
||||
|
||||
let singleton: UsePluginComponents | undefined;
|
||||
|
||||
export function setPluginComponentsHook(hook: UsePluginComponents): void {
|
||||
// We allow overriding the registry in tests
|
||||
// We allow overriding the hook in tests
|
||||
if (singleton && process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('setPluginComponentsHook() function should only be called once, when Grafana is starting.');
|
||||
}
|
||||
@@ -15,7 +25,7 @@ export function setPluginComponentsHook(hook: UsePluginComponents): void {
|
||||
}
|
||||
|
||||
export function usePluginComponents<Props extends object = {}>(
|
||||
options: GetPluginExtensionsOptions
|
||||
options: UsePluginComponentsOptions
|
||||
): UsePluginComponentsResult<Props> {
|
||||
if (!singleton) {
|
||||
throw new Error('setPluginComponentsHook(options) can only be used after the Grafana instance has started.');
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from './getPluginExtensions';
|
||||
import { PluginExtensionFunction } from '@grafana/data';
|
||||
|
||||
export type UsePluginFunctionsOptions = {
|
||||
extensionPointId: string;
|
||||
limitPerPlugin?: number;
|
||||
};
|
||||
|
||||
export type UsePluginFunctionsResult<Signature> = {
|
||||
isLoading: boolean;
|
||||
functions: Array<PluginExtensionFunction<Signature>>;
|
||||
};
|
||||
|
||||
export type UsePluginFunctions<T> = (options: UsePluginFunctionsOptions) => UsePluginFunctionsResult<T>;
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { UsePluginLinksOptions, UsePluginLinksResult } from './getPluginExtensions';
|
||||
import { PluginExtensionLink } from '@grafana/data';
|
||||
|
||||
export type UsePluginLinksOptions = {
|
||||
extensionPointId: string;
|
||||
context?: object | Record<string | symbol, unknown>;
|
||||
limitPerPlugin?: number;
|
||||
};
|
||||
|
||||
export type UsePluginLinksResult = {
|
||||
isLoading: boolean;
|
||||
links: PluginExtensionLink[];
|
||||
};
|
||||
|
||||
export type UsePluginLinks = (options: UsePluginLinksOptions) => UsePluginLinksResult;
|
||||
|
||||
|
||||
@@ -60,12 +60,15 @@ func (hs *HTTPServer) declareFixedRoles() error {
|
||||
Role: ac.RoleDTO{
|
||||
Name: "fixed:datasources:explorer",
|
||||
DisplayName: "Explorer",
|
||||
Description: "Enable the Explore feature. Data source permissions still apply; you can only query data sources for which you have query permissions.",
|
||||
Description: "Enable the Explore and Drilldown features. Data source permissions still apply; you can only query data sources for which you have query permissions.",
|
||||
Group: "Data sources",
|
||||
Permissions: []ac.Permission{
|
||||
{
|
||||
Action: ac.ActionDatasourcesExplore,
|
||||
},
|
||||
{
|
||||
Action: ac.ActionDatasourcesDrilldown,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleEditor)},
|
||||
|
||||
@@ -184,6 +184,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
}
|
||||
|
||||
r.Get("/explore", authorize(ac.EvalPermission(ac.ActionDatasourcesExplore)), hs.Index)
|
||||
r.Get("/drilldown", authorize(ac.EvalPermission(ac.ActionDatasourcesDrilldown)), hs.Index)
|
||||
|
||||
r.Get("/playlists/", reqSignedIn, hs.Index)
|
||||
r.Get("/playlists/*", reqSignedIn, hs.Index)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/authlib/types"
|
||||
)
|
||||
|
||||
@@ -30,27 +31,34 @@ func checkNilRequester(r Requester) bool {
|
||||
return r == nil || (reflect.ValueOf(r).Kind() == reflect.Ptr && reflect.ValueOf(r).IsNil())
|
||||
}
|
||||
|
||||
const serviceName = "service"
|
||||
const serviceNameForProvisioning = "provisioning"
|
||||
const (
|
||||
serviceName = "service"
|
||||
serviceNameForProvisioning = "provisioning"
|
||||
)
|
||||
|
||||
// WithServiceIdentity sets an identity representing the service itself in provided org and store it in context.
|
||||
// This is useful for background tasks that has to communicate with unfied storage. It also returns a Requester with
|
||||
// static permissions so it can be used in legacy code paths.
|
||||
func WithServiceIdentity(ctx context.Context, orgID int64) (context.Context, Requester) {
|
||||
r := &StaticRequester{
|
||||
func newInternalIdentity(name string, namespace string, orgID int64) Requester {
|
||||
return &StaticRequester{
|
||||
Type: types.TypeAccessPolicy,
|
||||
Name: serviceName,
|
||||
UserUID: serviceName,
|
||||
AuthID: serviceName,
|
||||
Login: serviceName,
|
||||
Name: name,
|
||||
UserUID: name,
|
||||
AuthID: name,
|
||||
Login: name,
|
||||
OrgRole: RoleAdmin,
|
||||
Namespace: namespace,
|
||||
IsGrafanaAdmin: true,
|
||||
OrgID: orgID,
|
||||
Permissions: map[int64]map[string][]string{
|
||||
orgID: serviceIdentityPermissions,
|
||||
},
|
||||
AccessTokenClaims: ServiceIdentityClaims,
|
||||
}
|
||||
}
|
||||
|
||||
// WithServiceIdentity sets an identity representing the service itself in provided org and store it in context.
|
||||
// This is useful for background tasks that has to communicate with unfied storage. It also returns a Requester with
|
||||
// static permissions so it can be used in legacy code paths.
|
||||
func WithServiceIdentity(ctx context.Context, orgID int64) (context.Context, Requester) {
|
||||
r := newInternalIdentity(serviceName, "", orgID)
|
||||
return WithRequester(ctx, r), r
|
||||
}
|
||||
|
||||
@@ -60,21 +68,7 @@ func WithProvisioningIdentitiy(ctx context.Context, namespace string) (context.C
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
r := &StaticRequester{
|
||||
Type: types.TypeAccessPolicy,
|
||||
Name: serviceNameForProvisioning,
|
||||
UserUID: serviceNameForProvisioning,
|
||||
AuthID: serviceNameForProvisioning,
|
||||
Login: serviceNameForProvisioning,
|
||||
OrgRole: RoleAdmin,
|
||||
IsGrafanaAdmin: true,
|
||||
Namespace: namespace,
|
||||
OrgID: ns.OrgID,
|
||||
Permissions: map[int64]map[string][]string{
|
||||
ns.OrgID: serviceIdentityPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
r := newInternalIdentity(serviceNameForProvisioning, ns.Value, ns.OrgID)
|
||||
return WithRequester(ctx, r), r, nil
|
||||
}
|
||||
|
||||
@@ -97,6 +91,14 @@ func getWildcardPermissions(actions ...string) map[string][]string {
|
||||
return permissions
|
||||
}
|
||||
|
||||
func getTokenPermissions(groups ...string) []string {
|
||||
out := make([]string, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
out = append(out, group+":*")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// serviceIdentityPermissions is a list of wildcard permissions for provided actions.
|
||||
// We should add every action required "internally" here.
|
||||
var serviceIdentityPermissions = getWildcardPermissions(
|
||||
@@ -118,6 +120,19 @@ var serviceIdentityPermissions = getWildcardPermissions(
|
||||
"teams:read", // accesscontrol.ActionTeamsRead,
|
||||
)
|
||||
|
||||
var serviceIdentityTokenPermissions = getTokenPermissions(
|
||||
"folder.grafana.app",
|
||||
"dashboard.grafana.app",
|
||||
"secret.grafana.app",
|
||||
)
|
||||
|
||||
var ServiceIdentityClaims = &authn.Claims[authn.AccessTokenClaims]{
|
||||
Rest: authn.AccessTokenClaims{
|
||||
Permissions: serviceIdentityTokenPermissions,
|
||||
DelegatedPermissions: serviceIdentityTokenPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
func IsServiceIdentity(ctx context.Context) bool {
|
||||
ident, ok := types.AuthInfoFrom(ctx)
|
||||
if !ok {
|
||||
|
||||
@@ -30,10 +30,11 @@ type StaticRequester struct {
|
||||
Namespace string
|
||||
IsGrafanaAdmin bool
|
||||
// Permissions grouped by orgID and actions
|
||||
Permissions map[int64]map[string][]string
|
||||
IDToken string
|
||||
IDTokenClaims *authnlib.Claims[authnlib.IDTokenClaims]
|
||||
CacheKey string
|
||||
Permissions map[int64]map[string][]string
|
||||
IDToken string
|
||||
IDTokenClaims *authnlib.Claims[authnlib.IDTokenClaims]
|
||||
AccessTokenClaims *authnlib.Claims[authnlib.AccessTokenClaims]
|
||||
CacheKey string
|
||||
}
|
||||
|
||||
// GetID returns typed id for the entity
|
||||
@@ -62,10 +63,16 @@ func (u *StaticRequester) GetAudience() []string {
|
||||
}
|
||||
|
||||
func (u *StaticRequester) GetTokenPermissions() []string {
|
||||
if u.AccessTokenClaims != nil {
|
||||
return u.AccessTokenClaims.Rest.Permissions
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (u *StaticRequester) GetTokenDelegatedPermissions() []string {
|
||||
if u.AccessTokenClaims != nil {
|
||||
return u.AccessTokenClaims.Rest.DelegatedPermissions
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
|
||||
+28
-13
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/build/gcloud/storage"
|
||||
"github.com/grafana/grafana/pkg/build/gcom"
|
||||
"github.com/grafana/grafana/pkg/build/packaging"
|
||||
"github.com/grafana/grafana/pkg/build/versions"
|
||||
)
|
||||
|
||||
const grafanaAPI = "https://grafana.com/api"
|
||||
@@ -45,6 +46,7 @@ func GrafanaCom(c *cli.Context) error {
|
||||
}
|
||||
|
||||
version := metadata.GrafanaVersion
|
||||
semver := versions.ParseSemver(version)
|
||||
if releaseMode.Mode == config.Cronjob {
|
||||
gcs, err := storage.New()
|
||||
if err != nil {
|
||||
@@ -69,7 +71,13 @@ func GrafanaCom(c *cli.Context) error {
|
||||
if grafanaAPIKey == "" {
|
||||
return cli.Exit("the environment variable GRAFANA_COM_API_KEY must be set", 1)
|
||||
}
|
||||
whatsNewURL, releaseNotesURL, err := getReleaseURLs()
|
||||
|
||||
pkgjson, err := getPackageJSON()
|
||||
if err != nil {
|
||||
return cli.Exit(err.Error(), 1)
|
||||
}
|
||||
|
||||
whatsNewURL, releaseNotesURL, err := getReleaseURLs(semver, pkgjson)
|
||||
if err != nil {
|
||||
return cli.Exit(err.Error(), 1)
|
||||
}
|
||||
@@ -97,25 +105,32 @@ func GrafanaCom(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getReleaseURLs() (string, string, error) {
|
||||
type grafanaConf struct {
|
||||
WhatsNewURL string `json:"whatsNewUrl"`
|
||||
ReleaseNotesURL string `json:"releaseNotesUrl"`
|
||||
}
|
||||
type packageConf struct {
|
||||
Grafana grafanaConf `json:"grafana"`
|
||||
}
|
||||
type grafanaConf struct {
|
||||
WhatsNewURL string `json:"whatsNewUrl"`
|
||||
ReleaseNotesURL string `json:"releaseNotesUrl"`
|
||||
}
|
||||
|
||||
type packageConf struct {
|
||||
Grafana grafanaConf `json:"grafana"`
|
||||
}
|
||||
|
||||
func getPackageJSON() (*packageConf, error) {
|
||||
pkgB, err := os.ReadFile("package.json")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to read package.json: %w", err)
|
||||
return nil, fmt.Errorf("failed to read package.json: %w", err)
|
||||
}
|
||||
|
||||
var pconf packageConf
|
||||
if err := json.Unmarshal(pkgB, &pconf); err != nil {
|
||||
return "", "", fmt.Errorf("failed to decode package.json: %w", err)
|
||||
return nil, fmt.Errorf("failed to decode package.json: %w", err)
|
||||
}
|
||||
if _, err := url.ParseRequestURI(pconf.Grafana.WhatsNewURL); err != nil {
|
||||
|
||||
return &pconf, nil
|
||||
}
|
||||
|
||||
func getReleaseURLs(semver versions.Semver, pconf *packageConf) (string, string, error) {
|
||||
u := fmt.Sprintf(pconf.Grafana.WhatsNewURL, semver.Major, semver.Minor, semver.Patch)
|
||||
if _, err := url.ParseRequestURI(u); err != nil {
|
||||
return "", "", fmt.Errorf("grafana.whatsNewUrl is invalid in package.json: %q", pconf.Grafana.WhatsNewURL)
|
||||
}
|
||||
if _, err := url.ParseRequestURI(pconf.Grafana.ReleaseNotesURL); err != nil {
|
||||
@@ -123,7 +138,7 @@ func getReleaseURLs() (string, string, error) {
|
||||
pconf.Grafana.ReleaseNotesURL)
|
||||
}
|
||||
|
||||
return pconf.Grafana.WhatsNewURL, pconf.Grafana.ReleaseNotesURL, nil
|
||||
return u, pconf.Grafana.ReleaseNotesURL, nil
|
||||
}
|
||||
|
||||
func Builds(baseURL *url.URL, grafana, version string, packages []packaging.BuildArtifact) ([]GCOMPackage, error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/build/packaging"
|
||||
"github.com/grafana/grafana/pkg/build/versions"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -258,3 +259,26 @@ func TestBuildsWithPlus(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseURLs(t *testing.T) {
|
||||
f := "https://grafana.com/whats-new-in-v%[1]s-%[2]s"
|
||||
|
||||
smv := versions.Semver{
|
||||
Major: "1",
|
||||
Minor: "2",
|
||||
Patch: "3",
|
||||
}
|
||||
|
||||
conf := packageConf{
|
||||
Grafana: grafanaConf{
|
||||
WhatsNewURL: f,
|
||||
ReleaseNotesURL: "https://example.com",
|
||||
},
|
||||
}
|
||||
|
||||
expect := "https://grafana.com/whats-new-in-v1-2"
|
||||
|
||||
a, _, err := getReleaseURLs(smv, &conf)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expect, a)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package versions
|
||||
|
||||
import "regexp"
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var semverRegex = regexp.MustCompile(`^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`)
|
||||
|
||||
@@ -13,6 +16,7 @@ type Semver struct {
|
||||
}
|
||||
|
||||
func ParseSemver(version string) Semver {
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
matches := semverRegex.FindStringSubmatch(version)
|
||||
results := make(map[string]string)
|
||||
for i, name := range semverRegex.SubexpNames() {
|
||||
|
||||
@@ -39,13 +39,13 @@ type OrgRecord struct {
|
||||
}
|
||||
|
||||
func NewGrafanaComProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGrafanaCom {
|
||||
s := newSocialBase(social.GrafanaComProviderName, orgRoleMapper, info, features, cfg)
|
||||
|
||||
// Override necessary settings
|
||||
info.AuthUrl = cfg.GrafanaComURL + "/oauth2/authorize"
|
||||
info.TokenUrl = cfg.GrafanaComURL + "/api/oauth2/token"
|
||||
info.AuthStyle = "inheader"
|
||||
|
||||
s := newSocialBase(social.GrafanaComProviderName, orgRoleMapper, info, features, cfg)
|
||||
|
||||
allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey])
|
||||
if err != nil {
|
||||
s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GrafanaComProviderName, "error", err)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
dashboard "github.com/grafana/grafana/pkg/apis/dashboard"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
@@ -86,7 +87,10 @@ func (a *dashboardSqlAccess) WriteEvent(ctx context.Context, event resource.Writ
|
||||
if a.subscribers != nil {
|
||||
go func() {
|
||||
write := &resource.WrittenEvent{
|
||||
WriteEvent: event,
|
||||
Type: event.Type,
|
||||
Key: event.Key,
|
||||
PreviousRV: event.PreviousRV,
|
||||
Value: event.Value,
|
||||
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
ResourceVersion: rv,
|
||||
|
||||
@@ -333,10 +333,14 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// hijacks the "name" query param to only search for shared dashboard UIDs
|
||||
if len(dashboardUIDs) > 0 {
|
||||
names = append(names, dashboardUIDs...)
|
||||
if len(dashboardUIDs) == 0 {
|
||||
s.write(w, dashboardv0alpha1.SearchResults{
|
||||
Hits: []dashboardv0alpha1.DashboardHit{},
|
||||
})
|
||||
return
|
||||
}
|
||||
// hijacks the "name" query param to only search for shared dashboard UIDs
|
||||
names = append(names, dashboardUIDs...)
|
||||
} else if folder != "" {
|
||||
if folder == rootFolder {
|
||||
folder = "" // root folder is empty in the search index
|
||||
|
||||
@@ -357,7 +357,131 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
|
||||
searchHandler.DoSearch(rr, req)
|
||||
|
||||
assert.Equal(t, mockClient.CallCount, 1)
|
||||
assert.Equal(t, mockClient.CallCount, 0)
|
||||
})
|
||||
|
||||
t.Run("should return empty result without searching if user does not have shared dashboards", func(t *testing.T) {
|
||||
mockClient := &MockClient{}
|
||||
|
||||
features := featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering)
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "test"),
|
||||
client: mockClient,
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
// "Permissions" prop in "SignedInUser" is where we store the uid of dashboards shared with the user
|
||||
// doesn't exist here, which represents a user without any shared dashboards
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||
|
||||
searchHandler.DoSearch(rr, req)
|
||||
|
||||
assert.Equal(t, mockClient.CallCount, 0)
|
||||
|
||||
resp := rr.Result()
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
p := &v0alpha1.SearchResults{}
|
||||
err := json.NewDecoder(resp.Body).Decode(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(p.Hits))
|
||||
})
|
||||
|
||||
t.Run("should return empty result if user has access to folder of all shared dashboards", func(t *testing.T) {
|
||||
// dashboardSearchRequest
|
||||
mockResponse1 := &resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "folder",
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "dashboardinroot",
|
||||
Resource: "dashboard",
|
||||
},
|
||||
Cells: [][]byte{[]byte("")}, // root folder doesn't have uid
|
||||
},
|
||||
{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "dashboardinpublicfolder",
|
||||
Resource: "dashboard",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("publicfolder"), // folder uid
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// folderSearchRequest
|
||||
mockResponse2 := &resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "folder",
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "publicfolder",
|
||||
Resource: "folder",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte(""), // root folder uid
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockClient := &MockClient{
|
||||
MockResponses: []*resource.ResourceSearchResponse{mockResponse1, mockResponse2},
|
||||
}
|
||||
|
||||
features := featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering)
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "test"),
|
||||
client: mockClient,
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
allPermissions := make(map[int64]map[string][]string)
|
||||
permissions := make(map[string][]string)
|
||||
permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinpublicfolder"}
|
||||
allPermissions[1] = permissions
|
||||
// "Permissions" is where we store the uid of dashboards shared with the user
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
|
||||
|
||||
searchHandler.DoSearch(rr, req)
|
||||
|
||||
assert.Equal(t, mockClient.CallCount, 2)
|
||||
|
||||
resp := rr.Result()
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
p := &v0alpha1.SearchResults{}
|
||||
err := json.NewDecoder(resp.Body).Decode(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(p.Hits))
|
||||
})
|
||||
|
||||
t.Run("should return the dashboards shared with the user", func(t *testing.T) {
|
||||
@@ -421,8 +545,29 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
mockResponse3 := &resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "folder",
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "dashboardinprivatefolder",
|
||||
Resource: "dashboard",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("privatefolder"), // folder uid
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockClient := &MockClient{
|
||||
MockResponses: []*resource.ResourceSearchResponse{mockResponse1, mockResponse2},
|
||||
MockResponses: []*resource.ResourceSearchResponse{mockResponse1, mockResponse2, mockResponse3},
|
||||
}
|
||||
|
||||
features := featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering)
|
||||
@@ -439,6 +584,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
permissions := make(map[string][]string)
|
||||
permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"}
|
||||
allPermissions[1] = permissions
|
||||
// "Permissions" is where we store the uid of dashboards shared with the user
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
|
||||
|
||||
searchHandler.DoSearch(rr, req)
|
||||
@@ -455,6 +601,18 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
// permission to read
|
||||
thirdCall := mockClient.MockCalls[2]
|
||||
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"})
|
||||
|
||||
resp := rr.Result()
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
p := &v0alpha1.SearchResults{}
|
||||
err := json.NewDecoder(resp.Body).Decode(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(mockResponse3.Results.Rows), len(p.Hits))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -386,7 +386,8 @@ const (
|
||||
ActionSettingsWrite = "settings:write"
|
||||
|
||||
// Datasources actions
|
||||
ActionDatasourcesExplore = "datasources:explore"
|
||||
ActionDatasourcesExplore = "datasources:explore"
|
||||
ActionDatasourcesDrilldown = "datasources:drilldown"
|
||||
|
||||
// Global Scopes
|
||||
ScopeGlobalUsersAll = "global.users:*"
|
||||
|
||||
@@ -149,5 +149,7 @@ func ProvideRegistration(
|
||||
|
||||
nsSync := sync.ProvideNamespaceSync(cfg)
|
||||
authnSvc.RegisterPostAuthHook(nsSync.SyncNamespace, 150)
|
||||
authnSvc.RegisterPostAuthHook(sync.AccessClaimsHook, 160)
|
||||
|
||||
return Registration{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
authnlib "github.com/grafana/authlib/authn"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
)
|
||||
|
||||
func NewAccessClaimsSync() AccessClaimsSync {
|
||||
return AccessClaimsSync{}
|
||||
}
|
||||
|
||||
type AccessClaimsSync struct{}
|
||||
|
||||
func AccessClaimsHook(ctx context.Context, id *authn.Identity, _ *authn.Request) error {
|
||||
if id.AccessTokenClaims == nil {
|
||||
// When normal authencation flows are used withint grafana we don't have any access token e.g. using user
|
||||
// session. This makes it impossible to authorize using AccessClient because we don't have any access claims
|
||||
// with deletegated permissions. To get around this we use the hardcoded delegated
|
||||
// permissions.
|
||||
id.AccessTokenClaims = &authnlib.Claims[authnlib.AccessTokenClaims]{
|
||||
Rest: authnlib.AccessTokenClaims{
|
||||
DelegatedPermissions: identity.ServiceIdentityClaims.Rest.DelegatedPermissions,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/grafana/authlib/types"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
@@ -22,7 +23,7 @@ import (
|
||||
)
|
||||
|
||||
func NewInProcGrpcAuthenticator() interceptors.Authenticator {
|
||||
return newAuthenticator(
|
||||
return NewAuthenticatorInterceptor(
|
||||
authn.NewDefaultAuthenticator(
|
||||
authn.NewUnsafeAccessTokenVerifier(authn.VerifierConfig{}),
|
||||
authn.NewUnsafeIDTokenVerifier(authn.VerifierConfig{}),
|
||||
@@ -46,7 +47,7 @@ func NewAuthenticator(cfg *GrpcServerConfig, tracer tracing.Tracer) interceptors
|
||||
authn.NewIDTokenVerifier(authn.VerifierConfig{}, kr),
|
||||
)
|
||||
|
||||
return newAuthenticator(auth, tracer)
|
||||
return NewAuthenticatorInterceptor(auth, tracer)
|
||||
}
|
||||
|
||||
func NewAuthenticatorWithFallback(cfg *setting.Cfg, reg prometheus.Registerer, tracer tracing.Tracer, fallback interceptors.Authenticator) interceptors.Authenticator {
|
||||
@@ -64,7 +65,7 @@ func NewAuthenticatorWithFallback(cfg *setting.Cfg, reg prometheus.Registerer, t
|
||||
}
|
||||
}
|
||||
|
||||
func newAuthenticator(auth authn.Authenticator, tracer tracing.Tracer) interceptors.Authenticator {
|
||||
func NewAuthenticatorInterceptor(auth authn.Authenticator, tracer trace.Tracer) interceptors.Authenticator {
|
||||
return interceptors.AuthenticatorFunc(func(ctx context.Context) (context.Context, error) {
|
||||
ctx, span := tracer.Start(ctx, "grpcutils.Authenticate")
|
||||
defer span.End()
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/go-jose/go-jose/v3/jwt"
|
||||
"github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
)
|
||||
|
||||
type inProcExchanger struct {
|
||||
@@ -37,8 +38,8 @@ func createInProcToken() (*authn.TokenExchangeResponse, error) {
|
||||
},
|
||||
Rest: authn.AccessTokenClaims{
|
||||
Namespace: "*",
|
||||
Permissions: []string{"folder.grafana.app:*", "dashboard.grafana.app:*"},
|
||||
DelegatedPermissions: []string{"folder.grafana.app:*", "dashboard.grafana.app:*"},
|
||||
Permissions: identity.ServiceIdentityClaims.Rest.Permissions,
|
||||
DelegatedPermissions: identity.ServiceIdentityClaims.Rest.DelegatedPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -12,27 +12,26 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
healthv1pb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
authnlib "github.com/grafana/authlib/authn"
|
||||
authzv1 "github.com/grafana/authlib/authz/proto/v1"
|
||||
claims "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/authlib/types"
|
||||
"github.com/grafana/dskit/services"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/authn/grpcutils"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/grpcserver"
|
||||
"github.com/grafana/grafana/pkg/services/grpcserver/interceptors"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
// ProvideZanzana used to register ZanzanaClient.
|
||||
// It will also start an embedded ZanzanaSever if mode is set to "embedded".
|
||||
func ProvideZanzana(cfg *setting.Cfg, db db.DB, features featuremgmt.FeatureToggles) (zanzana.Client, error) {
|
||||
func ProvideZanzana(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, features featuremgmt.FeatureToggles) (zanzana.Client, error) {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagZanzana) {
|
||||
return zanzana.NewNoopClient(), nil
|
||||
}
|
||||
@@ -82,7 +81,7 @@ func ProvideZanzana(cfg *setting.Cfg, db db.DB, features featuremgmt.FeatureTogg
|
||||
return nil, fmt.Errorf("failed to start zanzana: %w", err)
|
||||
}
|
||||
|
||||
srv, err := zanzana.NewServer(cfg.ZanzanaServer, openfga, logger)
|
||||
srv, err := zanzana.NewServer(cfg.ZanzanaServer, openfga, logger, tracer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start zanzana: %w", err)
|
||||
}
|
||||
@@ -90,7 +89,7 @@ func ProvideZanzana(cfg *setting.Cfg, db db.DB, features featuremgmt.FeatureTogg
|
||||
channel := &inprocgrpc.Channel{}
|
||||
// Put * as a namespace so we can properly authorize request with in-proc mode
|
||||
channel.WithServerUnaryInterceptor(grpcAuth.UnaryServerInterceptor(func(ctx context.Context) (context.Context, error) {
|
||||
ctx = claims.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{
|
||||
ctx = types.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{
|
||||
Rest: authnlib.AccessTokenClaims{
|
||||
Namespace: "*",
|
||||
},
|
||||
@@ -144,6 +143,18 @@ type Zanzana struct {
|
||||
}
|
||||
|
||||
func (z *Zanzana) start(ctx context.Context) error {
|
||||
tracingCfg, err := tracing.ProvideTracingConfig(z.cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracingCfg.ServiceName = "zanzana"
|
||||
|
||||
tracer, err := tracing.ProvideService(tracingCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store, err := zanzana.NewStore(z.cfg, z.logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initilize zanana store: %w", err)
|
||||
@@ -154,46 +165,27 @@ func (z *Zanzana) start(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to start zanzana: %w", err)
|
||||
}
|
||||
|
||||
zanzanaServer, err := zanzana.NewServer(z.cfg.ZanzanaServer, openfgaServer, z.logger)
|
||||
zanzanaServer, err := zanzana.NewServer(z.cfg.ZanzanaServer, openfgaServer, z.logger, tracer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start zanzana: %w", err)
|
||||
}
|
||||
|
||||
tracingCfg, err := tracing.ProvideTracingConfig(z.cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tracingCfg.ServiceName = "zanzana"
|
||||
|
||||
tracer, err := tracing.ProvideService(tracingCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authenticator := authnlib.NewAccessTokenAuthenticator(
|
||||
authnlib.NewAccessTokenVerifier(
|
||||
authnlib.VerifierConfig{
|
||||
AllowedAudiences: []string{authzServiceAudience},
|
||||
},
|
||||
authnlib.VerifierConfig{AllowedAudiences: []string{authzServiceAudience}},
|
||||
authnlib.NewKeyRetriever(authnlib.KeyRetrieverConfig{
|
||||
SigningKeysURL: z.cfg.ZanzanaServer.SigningKeysURL,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
authfn := interceptors.AuthenticatorFunc(func(ctx context.Context) (context.Context, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing metadata")
|
||||
}
|
||||
c, err := authenticator.Authenticate(ctx, authnlib.NewGRPCTokenProvider(md))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims.WithAuthInfo(ctx, c), nil
|
||||
})
|
||||
|
||||
z.handle, err = grpcserver.ProvideService(z.cfg, z.features, authfn, tracer, prometheus.DefaultRegisterer)
|
||||
z.handle, err = grpcserver.ProvideService(
|
||||
z.cfg,
|
||||
z.features,
|
||||
grpcutils.NewAuthenticatorInterceptor(authenticator, tracer),
|
||||
tracer,
|
||||
prometheus.DefaultRegisterer,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create zanzana grpc server: %w", err)
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ import (
|
||||
openfgastorage "github.com/openfga/openfga/pkg/storage"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/server"
|
||||
"github.com/grafana/grafana/pkg/services/grpcserver"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func NewServer(cfg setting.ZanzanaServerSettings, openfga server.OpenFGAServer, logger log.Logger) (*server.Server, error) {
|
||||
return server.NewServer(cfg, openfga, logger)
|
||||
func NewServer(cfg setting.ZanzanaServerSettings, openfga server.OpenFGAServer, logger log.Logger, tracer tracing.Tracer) (*server.Server, error) {
|
||||
return server.NewServer(cfg, openfga, logger, tracer)
|
||||
}
|
||||
|
||||
func NewHealthServer(target server.DiagnosticServer) *server.HealthServer {
|
||||
|
||||
@@ -36,9 +36,6 @@ func NewOpenFGAServer(cfg setting.ZanzanaServerSettings, store storage.OpenFGADa
|
||||
server.WithListObjectsDeadline(cfg.ListObjectsDeadline),
|
||||
}
|
||||
|
||||
// FIXME(kalleep): Interceptors
|
||||
// We probably need to at least need to add store id interceptor also
|
||||
// would be nice to inject our own requestid?
|
||||
srv, err := server.NewServerWithOpts(opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
"github.com/fullstorydev/grpchan/inprocgrpc"
|
||||
authzv1 "github.com/grafana/authlib/authz/proto/v1"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"go.opentelemetry.io/otel"
|
||||
"google.golang.org/protobuf/types/known/wrapperspb"
|
||||
|
||||
dashboardalpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -25,8 +25,6 @@ const cacheCleanInterval = 2 * time.Minute
|
||||
var _ authzv1.AuthzServiceServer = (*Server)(nil)
|
||||
var _ authzextv1.AuthzExtentionServiceServer = (*Server)(nil)
|
||||
|
||||
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/authz/zanzana/server")
|
||||
|
||||
type OpenFGAServer interface {
|
||||
openfgav1.OpenFGAServiceServer
|
||||
IsReady(ctx context.Context) (bool, error)
|
||||
@@ -40,10 +38,12 @@ type Server struct {
|
||||
openfgaClient openfgav1.OpenFGAServiceClient
|
||||
|
||||
cfg setting.ZanzanaServerSettings
|
||||
logger log.Logger
|
||||
stores map[string]storeInfo
|
||||
storesMU *sync.Mutex
|
||||
cache *localcache.CacheService
|
||||
|
||||
logger log.Logger
|
||||
tracer tracing.Tracer
|
||||
}
|
||||
|
||||
type storeInfo struct {
|
||||
@@ -51,7 +51,7 @@ type storeInfo struct {
|
||||
ModelID string
|
||||
}
|
||||
|
||||
func NewServer(cfg setting.ZanzanaServerSettings, openfga OpenFGAServer, logger log.Logger) (*Server, error) {
|
||||
func NewServer(cfg setting.ZanzanaServerSettings, openfga OpenFGAServer, logger log.Logger, tracer tracing.Tracer) (*Server, error) {
|
||||
channel := &inprocgrpc.Channel{}
|
||||
openfgav1.RegisterOpenFGAServiceServer(channel, openfga)
|
||||
openFGAClient := openfgav1.NewOpenFGAServiceClient(channel)
|
||||
@@ -64,6 +64,7 @@ func NewServer(cfg setting.ZanzanaServerSettings, openfga OpenFGAServer, logger
|
||||
cfg: cfg,
|
||||
cache: localcache.New(cfg.CheckQueryCacheTTL, cacheCleanInterval),
|
||||
logger: logger,
|
||||
tracer: tracer,
|
||||
}
|
||||
|
||||
return s, nil
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) BatchCheck(ctx context.Context, r *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) {
|
||||
ctx, span := tracer.Start(ctx, "server.BatchCheck")
|
||||
ctx, span := s.tracer.Start(ctx, "server.BatchCheck")
|
||||
defer span.End()
|
||||
|
||||
if err := authorize(ctx, r.GetNamespace()); err != nil {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) Check(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) {
|
||||
ctx, span := tracer.Start(ctx, "server.Check")
|
||||
ctx, span := s.tracer.Start(ctx, "server.Check")
|
||||
defer span.End()
|
||||
|
||||
if err := authorize(ctx, r.GetNamespace()); err != nil {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) List(ctx context.Context, r *authzv1.ListRequest) (*authzv1.ListResponse, error) {
|
||||
ctx, span := tracer.Start(ctx, "server.List")
|
||||
ctx, span := s.tracer.Start(ctx, "server.List")
|
||||
defer span.End()
|
||||
|
||||
if err := authorize(ctx, r.GetNamespace()); err != nil {
|
||||
@@ -141,7 +141,7 @@ func (s *Server) listObjects(ctx context.Context, req *openfgav1.ListObjectsRequ
|
||||
type listFn func(ctx context.Context, req *openfgav1.ListObjectsRequest) (*openfgav1.ListObjectsResponse, error)
|
||||
|
||||
func (s *Server) listObjectCached(ctx context.Context, req *openfgav1.ListObjectsRequest, fn listFn) (*openfgav1.ListObjectsResponse, error) {
|
||||
ctx, span := tracer.Start(ctx, "server.listObjectCached")
|
||||
ctx, span := s.tracer.Start(ctx, "server.listObjectCached")
|
||||
defer span.End()
|
||||
|
||||
key, err := getRequestHash(req)
|
||||
@@ -163,7 +163,7 @@ func (s *Server) listObjectCached(ctx context.Context, req *openfgav1.ListObject
|
||||
}
|
||||
|
||||
func (s *Server) streamedListObjects(ctx context.Context, req *openfgav1.ListObjectsRequest) (*openfgav1.ListObjectsResponse, error) {
|
||||
ctx, span := tracer.Start(ctx, "server.streamedListObjects")
|
||||
ctx, span := s.tracer.Start(ctx, "server.streamedListObjects")
|
||||
defer span.End()
|
||||
|
||||
r := &openfgav1.StreamedListObjectsRequest{
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authzextv1.ReadResponse, error) {
|
||||
ctx, span := tracer.Start(ctx, "server.Read")
|
||||
ctx, span := s.tracer.Start(ctx, "server.Read")
|
||||
defer span.End()
|
||||
|
||||
if err := authorize(ctx, req.GetNamespace()); err != nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/store"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
@@ -75,7 +76,7 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
|
||||
openfga, err := NewOpenFGAServer(cfg.ZanzanaServer, store, log.NewNopLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
srv, err := NewServer(cfg.ZanzanaServer, openfga, log.NewNopLogger())
|
||||
srv, err := NewServer(cfg.ZanzanaServer, openfga, log.NewNopLogger(), tracing.NewNoopTracerService())
|
||||
require.NoError(t, err)
|
||||
|
||||
storeInf, err := srv.getStoreInfo(context.Background(), namespace)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*authzextv1.WriteResponse, error) {
|
||||
ctx, span := tracer.Start(ctx, "server.Write")
|
||||
ctx, span := s.tracer.Start(ctx, "server.Write")
|
||||
defer span.End()
|
||||
|
||||
if err := authorize(ctx, req.GetNamespace()); err != nil {
|
||||
|
||||
@@ -400,6 +400,20 @@ func saveDashboard(sess *db.Session, cmd *dashboards.SaveDashboardCommand, emitE
|
||||
userId = -1
|
||||
}
|
||||
|
||||
// we don't save FolderID in kubernetes object when saving through k8s
|
||||
// this block guarantees we save dashboards with folder_id and folder_uid in those cases
|
||||
if !dash.IsFolder && dash.FolderUID != "" && dash.FolderID == 0 { // nolint:staticcheck
|
||||
var existing dashboards.Dashboard
|
||||
folderIdFound, err := sess.Where("uid=? AND org_id=?", dash.FolderUID, dash.OrgID).Get(&existing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if folderIdFound {
|
||||
dash.FolderID = existing.ID // nolint:staticcheck
|
||||
}
|
||||
}
|
||||
|
||||
if dash.ID > 0 {
|
||||
var existing dashboards.Dashboard
|
||||
dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.ID, dash.OrgID).Get(&existing)
|
||||
|
||||
@@ -378,6 +378,23 @@ func TestIntegrationDashboardDataAccess(t *testing.T) {
|
||||
require.False(t, dashboard.Updated.IsZero())
|
||||
})
|
||||
|
||||
t.Run("Should populate FolderID if FolderUID is provided when creating a dashboard", func(t *testing.T) {
|
||||
setup()
|
||||
cmd := dashboards.SaveDashboardCommand{
|
||||
OrgID: 1,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
"title": "folderId",
|
||||
"tags": []interface{}{},
|
||||
}),
|
||||
FolderUID: savedFolder.UID,
|
||||
UserID: 100,
|
||||
}
|
||||
dashboard, err := dashboardStore.SaveDashboard(context.Background(), cmd)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, dashboard.FolderID, savedFolder.ID) //nolint:staticcheck
|
||||
require.EqualValues(t, dashboard.FolderUID, savedFolder.UID)
|
||||
})
|
||||
|
||||
t.Run("Should be able to update dashboard by id and remove folderId", func(t *testing.T) {
|
||||
setup()
|
||||
cmd := dashboards.SaveDashboardCommand{
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
WeightSavedItems
|
||||
WeightDashboard
|
||||
WeightExplore
|
||||
WeightDrilldown
|
||||
WeightAlerting
|
||||
WeightAlertsAndIncidents
|
||||
WeightTestingAndSynthetics
|
||||
@@ -37,6 +38,7 @@ const (
|
||||
NavIDRoot = "root"
|
||||
NavIDDashboards = "dashboards/browse"
|
||||
NavIDExplore = "explore"
|
||||
NavIDDrilldown = "drilldown"
|
||||
NavIDCfg = "cfg" // NavIDCfg is the id for org configuration navigation node
|
||||
NavIDAlertsAndIncidents = "alerts-and-incidents"
|
||||
NavIDTestingAndSynthetics = "testing-and-synthetics"
|
||||
@@ -70,6 +72,7 @@ type NavLink struct {
|
||||
EmptyMessageId string `json:"emptyMessageId,omitempty"`
|
||||
PluginID string `json:"pluginId,omitempty"` // (Optional) The ID of the plugin that registered nav link (e.g. as a standalone plugin page)
|
||||
IsCreateAction bool `json:"isCreateAction,omitempty"`
|
||||
IsNew bool `json:"isNew,omitempty"` // (Optional) Adds "New!" badge to the nav link and expands it by default
|
||||
Keywords []string `json:"keywords,omitempty"`
|
||||
ParentItem *NavLink `json:"parentItem,omitempty"` // (Optional) The parent item of the nav link
|
||||
}
|
||||
|
||||
@@ -286,9 +286,9 @@ func (s *ServiceImpl) readNavigationSettings() {
|
||||
"grafana-k8s-app": {SectionID: navtree.NavIDInfrastructure, SortWeight: 1, Text: "Kubernetes"},
|
||||
"grafana-dbo11y-app": {SectionID: navtree.NavIDInfrastructure, SortWeight: 2, Text: "Databases"},
|
||||
"grafana-app-observability-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightApplication, Text: "Application", Icon: "graph-bar"},
|
||||
"grafana-lokiexplore-app": {SectionID: navtree.NavIDExplore, SortWeight: 2, Text: "Logs"},
|
||||
"grafana-exploretraces-app": {SectionID: navtree.NavIDExplore, SortWeight: 3, Text: "Traces"},
|
||||
"grafana-pyroscope-app": {SectionID: navtree.NavIDExplore, SortWeight: 4, Text: "Profiles"},
|
||||
"grafana-lokiexplore-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 2, Text: "Logs"},
|
||||
"grafana-exploretraces-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 3, Text: "Traces"},
|
||||
"grafana-pyroscope-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 4, Text: "Profiles"},
|
||||
"grafana-kowalski-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightFrontend, Text: "Frontend", Icon: "frontend-observability"},
|
||||
"grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDTestingAndSynthetics, SortWeight: 2, Text: "Synthetics"},
|
||||
"grafana-irm-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1, Text: "IRM"},
|
||||
@@ -310,7 +310,11 @@ func (s *ServiceImpl) readNavigationSettings() {
|
||||
}
|
||||
|
||||
if s.features.IsEnabledGlobally(featuremgmt.FlagExploreMetricsUseExternalAppPlugin) {
|
||||
s.navigationAppConfig["grafana-metricsdrilldown-app"] = NavigationAppConfig{SectionID: navtree.NavIDExplore, SortWeight: 1, Text: "Metrics"}
|
||||
s.navigationAppConfig["grafana-metricsdrilldown-app"] = NavigationAppConfig{SectionID: navtree.NavIDDrilldown, SortWeight: 1, Text: "Metrics"}
|
||||
}
|
||||
|
||||
if s.features.IsEnabledGlobally(featuremgmt.FlagGrafanaAdvisor) {
|
||||
s.navigationAppConfig["grafana-advisor-app"] = NavigationAppConfig{SectionID: navtree.NavIDCfg, Text: "Advisor"}
|
||||
}
|
||||
|
||||
s.navigationAppPathConfig = map[string]NavigationAppConfig{
|
||||
|
||||
@@ -120,7 +120,6 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere
|
||||
}
|
||||
|
||||
if s.cfg.ExploreEnabled && hasAccess(ac.EvalPermission(ac.ActionDatasourcesExplore)) {
|
||||
exploreChildNavLinks := s.buildExploreNavLinks(c)
|
||||
treeRoot.AddSection(&navtree.NavLink{
|
||||
Text: "Explore",
|
||||
Id: navtree.NavIDExplore,
|
||||
@@ -128,7 +127,20 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere
|
||||
Icon: "compass",
|
||||
SortWeight: navtree.WeightExplore,
|
||||
Url: s.cfg.AppSubURL + "/explore",
|
||||
Children: exploreChildNavLinks,
|
||||
})
|
||||
}
|
||||
|
||||
if hasAccess(ac.EvalPermission(ac.ActionDatasourcesDrilldown)) {
|
||||
drilldownChildNavLinks := s.buildDrilldownNavLinks(c)
|
||||
treeRoot.AddSection(&navtree.NavLink{
|
||||
Text: "Drilldown",
|
||||
Id: navtree.NavIDDrilldown,
|
||||
SubTitle: "Drill down into your data using Grafana's powerful queryless apps",
|
||||
Icon: "drilldown",
|
||||
IsNew: true,
|
||||
SortWeight: navtree.WeightDrilldown,
|
||||
Url: s.cfg.AppSubURL + "/drilldown",
|
||||
Children: drilldownChildNavLinks,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -560,10 +572,10 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *n
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) buildExploreNavLinks(c *contextmodel.ReqContext) []*navtree.NavLink {
|
||||
exploreChildNavs := []*navtree.NavLink{}
|
||||
func (s *ServiceImpl) buildDrilldownNavLinks(c *contextmodel.ReqContext) []*navtree.NavLink {
|
||||
drilldownChildNavs := []*navtree.NavLink{}
|
||||
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagExploreMetrics) && !s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagExploreMetricsUseExternalAppPlugin) {
|
||||
exploreChildNavs = append(exploreChildNavs, &navtree.NavLink{
|
||||
drilldownChildNavs = append(drilldownChildNavs, &navtree.NavLink{
|
||||
Text: "Metrics",
|
||||
SubTitle: "Queryless exploration of your metrics",
|
||||
Id: "explore/metrics",
|
||||
@@ -571,5 +583,5 @@ func (s *ServiceImpl) buildExploreNavLinks(c *contextmodel.ReqContext) []*navtre
|
||||
Icon: "code-branch",
|
||||
})
|
||||
}
|
||||
return exploreChildNavs
|
||||
return drilldownChildNavs
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrImageNotFound is returned when the image does not exist.
|
||||
// ErrImageNotFound is returned when the image does not exist or is expired.
|
||||
ErrImageNotFound = errors.New("image not found")
|
||||
|
||||
// ErrImageDataUnavailable is returned when image data is unavailable. Usually because the image is missing a path.
|
||||
ErrImageDataUnavailable = errors.New("image data is unavailable")
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
|
||||
@@ -6,145 +6,61 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
alertingImages "github.com/grafana/alerting/images"
|
||||
alertingModels "github.com/grafana/alerting/models"
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
)
|
||||
|
||||
type imageProvider struct {
|
||||
type tokenStore struct {
|
||||
store store.ImageStore
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
var _ alertingImages.TokenStore = (*tokenStore)(nil)
|
||||
|
||||
func newImageProvider(store store.ImageStore, logger log.Logger) alertingImages.Provider {
|
||||
return &imageProvider{
|
||||
return alertingImages.NewTokenProvider(&tokenStore{
|
||||
store: store,
|
||||
logger: logger,
|
||||
}
|
||||
}, newLogWrapper(logger))
|
||||
}
|
||||
|
||||
func (i imageProvider) GetImage(ctx context.Context, uri string) (*alertingImages.Image, error) {
|
||||
image, err := i.getImageFromURI(ctx, uri)
|
||||
func (t tokenStore) GetImage(ctx context.Context, token string) (*alertingImages.Image, error) {
|
||||
image, err := t.store.GetImage(ctx, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.ErrImageNotFound) {
|
||||
i.logger.Info("Image not found in database", "uri", uri)
|
||||
return nil, alertingImages.ErrImageNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &alertingImages.Image{
|
||||
Token: image.Token,
|
||||
Path: image.Path,
|
||||
URL: image.URL,
|
||||
CreatedAt: image.CreatedAt,
|
||||
URL: image.URL,
|
||||
RawData: func(_ context.Context) (alertingImages.ImageContent, error) {
|
||||
if image.Path == "" {
|
||||
return alertingImages.ImageContent{}, models.ErrImageDataUnavailable
|
||||
}
|
||||
b, err := readImage(image.Path, t.logger)
|
||||
if err != nil {
|
||||
return alertingImages.ImageContent{}, err
|
||||
}
|
||||
return alertingImages.ImageContent{
|
||||
Name: filepath.Base(image.Path),
|
||||
Content: b,
|
||||
}, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (i imageProvider) GetImageURL(ctx context.Context, alert *alertingNotify.Alert) (string, error) {
|
||||
uri, err := getImageURI(alert)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// If the identifier is a URL, validate that it corresponds to a stored, non-expired image.
|
||||
if strings.HasPrefix(uri, "http") {
|
||||
i.logger.Debug("Received an image URL in annotations", "alert", alert)
|
||||
exists, err := i.store.URLExists(ctx, uri)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
i.logger.Info("Image URL not found in database", "alert", alert)
|
||||
return "", alertingImages.ErrImageNotFound
|
||||
}
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
// If the identifier is a token, remove the prefix, get the image and return the URL.
|
||||
token := strings.TrimPrefix(uri, "token://")
|
||||
i.logger.Debug("Received an image token in annotations", "alert", alert, "token", token)
|
||||
return i.getImageURLFromToken(ctx, token)
|
||||
}
|
||||
|
||||
// getImageURLFromToken takes a token and returns the URL of the image that token belongs to.
|
||||
func (i imageProvider) getImageURLFromToken(ctx context.Context, token string) (string, error) {
|
||||
image, err := i.store.GetImage(ctx, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.ErrImageNotFound) {
|
||||
i.logger.Info("Image not found in database", "token", token)
|
||||
return "", alertingImages.ErrImageNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !image.HasURL() {
|
||||
return "", alertingImages.ErrImagesNoURL
|
||||
}
|
||||
return image.URL, nil
|
||||
}
|
||||
|
||||
func (i imageProvider) GetRawImage(ctx context.Context, alert *alertingNotify.Alert) (io.ReadCloser, string, error) {
|
||||
uri, err := getImageURI(alert)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
image, err := i.getImageFromURI(ctx, uri)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.ErrImageNotFound) {
|
||||
i.logger.Info("Image not found in database", "alert", alert)
|
||||
return nil, "", alertingImages.ErrImageNotFound
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
if !image.HasPath() {
|
||||
return nil, "", alertingImages.ErrImagesNoPath
|
||||
}
|
||||
|
||||
// Return image bytes and filename.
|
||||
readCloser, err := openImage(image.Path)
|
||||
if err != nil {
|
||||
i.logger.Error("Error looking for image on disk", "alert", alert, "path", image.Path, "error", err)
|
||||
return nil, "", err
|
||||
}
|
||||
filename := filepath.Base(image.Path)
|
||||
return readCloser, filename, nil
|
||||
}
|
||||
|
||||
func (i imageProvider) getImageFromURI(ctx context.Context, uri string) (*models.Image, error) {
|
||||
// Check whether the uri is a URL or a token to know how to query the DB.
|
||||
if strings.HasPrefix(uri, "http") {
|
||||
i.logger.Debug("Received an image URL in annotations")
|
||||
return i.store.GetImageByURL(ctx, uri)
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(uri, "token://")
|
||||
i.logger.Debug("Received an image token in annotations", "token", token)
|
||||
return i.store.GetImage(ctx, token)
|
||||
}
|
||||
|
||||
// getImageURI is a helper function to retrieve the image URI from the alert annotations as a string.
|
||||
func getImageURI(alert *alertingNotify.Alert) (string, error) {
|
||||
uri, ok := alert.Annotations[alertingModels.ImageTokenAnnotation]
|
||||
if !ok {
|
||||
return "", alertingImages.ErrNoImageForAlert
|
||||
}
|
||||
return string(uri), nil
|
||||
}
|
||||
|
||||
// openImage returns an the io representation of an image from the given path.
|
||||
func openImage(path string) (io.ReadCloser, error) {
|
||||
// readImage returns an image from the given path.
|
||||
func readImage(path string, logger log.Logger) ([]byte, error) {
|
||||
fp := filepath.Clean(path)
|
||||
_, err := os.Stat(fp)
|
||||
if os.IsNotExist(err) || os.IsPermission(err) {
|
||||
return nil, alertingImages.ErrImageNotFound
|
||||
return nil, models.ErrImageNotFound
|
||||
}
|
||||
|
||||
f, err := os.Open(fp)
|
||||
@@ -152,5 +68,11 @@ func openImage(path string) (io.ReadCloser, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
logger.Error("Failed to close image file", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return io.ReadAll(f)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -11,177 +10,98 @@ import (
|
||||
alertingImages "github.com/grafana/alerting/images"
|
||||
alertingModels "github.com/grafana/alerting/models"
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetImage(t *testing.T) {
|
||||
fakeImageStore := store.NewFakeImageStore(t)
|
||||
store := newImageProvider(fakeImageStore, log.NewNopLogger())
|
||||
testBytes := []byte("some test bytes")
|
||||
testPath := generateTestFile(t, testBytes)
|
||||
|
||||
t.Run("queries by token when it gets a token", func(tt *testing.T) {
|
||||
img := models.Image{
|
||||
Token: "test",
|
||||
URL: "http://localhost:1234",
|
||||
Path: "test.png",
|
||||
}
|
||||
err := fakeImageStore.SaveImage(context.Background(), &img)
|
||||
require.NoError(tt, err)
|
||||
|
||||
// nolint:staticcheck
|
||||
savedImg, err := store.GetImage(context.Background(), "token://"+img.Token)
|
||||
require.NoError(tt, err)
|
||||
require.Equal(tt, savedImg.Token, img.Token)
|
||||
require.Equal(tt, savedImg.URL, img.URL)
|
||||
require.Equal(tt, savedImg.Path, img.Path)
|
||||
})
|
||||
|
||||
t.Run("queries by URL when it gets a URL", func(tt *testing.T) {
|
||||
img := models.Image{
|
||||
Token: "test",
|
||||
Path: "test.png",
|
||||
URL: "https://test.com/test.png",
|
||||
}
|
||||
err := fakeImageStore.SaveImage(context.Background(), &img)
|
||||
require.NoError(tt, err)
|
||||
|
||||
// nolint:staticcheck
|
||||
savedImg, err := store.GetImage(context.Background(), img.URL)
|
||||
require.NoError(tt, err)
|
||||
require.Equal(tt, savedImg.Token, img.Token)
|
||||
require.Equal(tt, savedImg.URL, img.URL)
|
||||
require.Equal(tt, savedImg.Path, img.Path)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetImageURL(t *testing.T) {
|
||||
var (
|
||||
imageWithoutURL = models.Image{
|
||||
Token: "test-no-url",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
|
||||
}
|
||||
testImage = models.Image{
|
||||
Token: "test",
|
||||
imageWithoutPath = models.Image{
|
||||
Token: "test-token-no-path",
|
||||
URL: "https://test.com",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
|
||||
}
|
||||
)
|
||||
|
||||
fakeImageStore := store.NewFakeImageStore(t, &imageWithoutURL, &testImage)
|
||||
store := newImageProvider(fakeImageStore, log.NewNopLogger())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uri string
|
||||
expURL string
|
||||
expErr error
|
||||
}{
|
||||
{
|
||||
"URL does not exist",
|
||||
"https://invalid.com/test",
|
||||
"",
|
||||
alertingImages.ErrImageNotFound,
|
||||
}, {
|
||||
"existing URL",
|
||||
testImage.URL,
|
||||
testImage.URL,
|
||||
nil,
|
||||
}, {
|
||||
"token does not exist",
|
||||
"token://invalid",
|
||||
"",
|
||||
alertingImages.ErrImageNotFound,
|
||||
}, {
|
||||
"existing token",
|
||||
"token://" + testImage.Token,
|
||||
testImage.URL,
|
||||
nil,
|
||||
}, {
|
||||
"image has no URL",
|
||||
"token://" + imageWithoutURL.Token,
|
||||
"",
|
||||
alertingImages.ErrImagesNoURL,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(tt *testing.T) {
|
||||
alert := alertingNotify.Alert{
|
||||
Alert: model.Alert{
|
||||
Annotations: model.LabelSet{alertingModels.ImageTokenAnnotation: model.LabelValue(test.uri)},
|
||||
},
|
||||
}
|
||||
url, err := store.GetImageURL(context.Background(), &alert)
|
||||
require.ErrorIs(tt, err, test.expErr)
|
||||
require.Equal(tt, test.expURL, url)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRawImage(t *testing.T) {
|
||||
var (
|
||||
testBytes = []byte("some test bytes")
|
||||
testPath = generateTestFile(t, testBytes)
|
||||
imageWithoutPath = models.Image{
|
||||
Token: "test-no-path",
|
||||
URL: "https://test-no-path.com",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
|
||||
}
|
||||
testImage = models.Image{
|
||||
Token: "test",
|
||||
Token: "test-token",
|
||||
URL: "https://test.com",
|
||||
Path: testPath,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
|
||||
}
|
||||
testImageMissingFile = models.Image{
|
||||
Token: "test-token-missing-file",
|
||||
URL: "https://test.com",
|
||||
Path: "/tmp/missing/1234asdf.png",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
|
||||
}
|
||||
)
|
||||
|
||||
fakeImageStore := store.NewFakeImageStore(t, &imageWithoutPath, &testImage)
|
||||
fakeImageStore := store.NewFakeImageStore(t, &imageWithoutPath, &testImage, &testImageMissingFile)
|
||||
store := newImageProvider(fakeImageStore, log.NewNopLogger())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uri string
|
||||
expFilename string
|
||||
expBytes []byte
|
||||
expErr error
|
||||
name string
|
||||
token string
|
||||
url string
|
||||
expImage *alertingImages.Image
|
||||
expImageContent *alertingImages.ImageContent
|
||||
expRawDataErr error
|
||||
}{
|
||||
{
|
||||
"URL does not exist",
|
||||
"https://invalid.com/test",
|
||||
"",
|
||||
nil,
|
||||
alertingImages.ErrImageNotFound,
|
||||
name: "Given existing raw token, expect image",
|
||||
token: testImage.Token,
|
||||
expImage: &alertingImages.Image{
|
||||
URL: testImage.URL,
|
||||
},
|
||||
}, {
|
||||
"existing URL",
|
||||
testImage.URL,
|
||||
filepath.Base(testPath),
|
||||
testBytes,
|
||||
nil,
|
||||
name: "Given existing token and url, expect image",
|
||||
token: testImage.Token,
|
||||
url: testImage.URL,
|
||||
expImage: &alertingImages.Image{
|
||||
URL: testImage.URL,
|
||||
},
|
||||
}, {
|
||||
"token does not exist",
|
||||
"token://invalid",
|
||||
"",
|
||||
nil,
|
||||
alertingImages.ErrImageNotFound,
|
||||
name: "Given existing with just url, expect nil",
|
||||
token: "",
|
||||
url: testImage.URL,
|
||||
expImage: nil,
|
||||
}, {
|
||||
"existing token",
|
||||
"token://" + testImage.Token,
|
||||
filepath.Base(testPath),
|
||||
testBytes,
|
||||
nil,
|
||||
name: "Given missing raw token, expect nil",
|
||||
token: "invalid",
|
||||
expImage: nil,
|
||||
}, {
|
||||
"image has no path",
|
||||
"token://" + imageWithoutPath.Token,
|
||||
"",
|
||||
nil,
|
||||
alertingImages.ErrImagesNoPath,
|
||||
name: "Given image with Path, expect RawData",
|
||||
token: testImage.Token,
|
||||
expImage: &alertingImages.Image{
|
||||
URL: testImage.URL,
|
||||
},
|
||||
expImageContent: &alertingImages.ImageContent{
|
||||
Name: filepath.Base(testImage.Path),
|
||||
Content: testBytes,
|
||||
},
|
||||
}, {
|
||||
name: "Given image with Path but file doesn't exist, expect RawData error",
|
||||
token: testImageMissingFile.Token,
|
||||
expImage: &alertingImages.Image{
|
||||
URL: testImageMissingFile.URL,
|
||||
},
|
||||
expRawDataErr: models.ErrImageNotFound,
|
||||
}, {
|
||||
name: "Given image without Path, expect RawData error",
|
||||
token: imageWithoutPath.Token,
|
||||
expImage: &alertingImages.Image{
|
||||
URL: imageWithoutPath.URL,
|
||||
},
|
||||
expRawDataErr: models.ErrImageDataUnavailable,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -189,18 +109,24 @@ func TestGetRawImage(t *testing.T) {
|
||||
t.Run(test.name, func(tt *testing.T) {
|
||||
alert := alertingNotify.Alert{
|
||||
Alert: model.Alert{
|
||||
Annotations: model.LabelSet{alertingModels.ImageTokenAnnotation: model.LabelValue(test.uri)},
|
||||
Annotations: model.LabelSet{alertingModels.ImageTokenAnnotation: model.LabelValue(test.token)},
|
||||
},
|
||||
}
|
||||
readCloser, filename, err := store.GetRawImage(context.Background(), &alert)
|
||||
require.ErrorIs(tt, err, test.expErr)
|
||||
require.Equal(tt, test.expFilename, filename)
|
||||
|
||||
if test.expBytes != nil {
|
||||
b, err := io.ReadAll(readCloser)
|
||||
image, err := store.GetImage(context.Background(), alert)
|
||||
require.NoError(tt, err)
|
||||
if test.expImage == nil {
|
||||
require.Nil(tt, image)
|
||||
return
|
||||
}
|
||||
require.Equal(tt, test.expImage.URL, image.URL)
|
||||
if test.expImageContent != nil {
|
||||
ic, err := image.RawData(context.Background())
|
||||
require.NoError(tt, err)
|
||||
require.Equal(tt, test.expBytes, b)
|
||||
require.NoError(t, readCloser.Close())
|
||||
require.Equal(tt, *test.expImageContent, ic)
|
||||
}
|
||||
if test.expRawDataErr != nil {
|
||||
_, err := image.RawData(context.Background())
|
||||
require.ErrorIs(tt, err, test.expRawDataErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ var LoggerFactory alertingLogging.LoggerFactory = func(logger string, ctx ...any
|
||||
return &logWrapper{log.New(append([]any{logger}, ctx...)...)}
|
||||
}
|
||||
|
||||
func newLogWrapper(logger log.Logger, ctx ...any) alertingLogging.Logger {
|
||||
return &logWrapper{logger.New(ctx...)}
|
||||
}
|
||||
|
||||
type logWrapper struct {
|
||||
*log.ConcreteLogger
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -48,8 +49,8 @@ func StateToPostableAlert(transition StateTransition, appURL *url.URL) *models.P
|
||||
nA[alertingModels.ValueStringAnnotation] = alertState.LastEvaluationString
|
||||
}
|
||||
|
||||
if alertState.Image != nil && alertState.Image.Token != "" {
|
||||
nA[alertingModels.ImageTokenAnnotation] = alertState.Image.Token
|
||||
if alertState.Image != nil {
|
||||
attachImageAnnotations(alertState.Image, nA)
|
||||
}
|
||||
|
||||
if alertState.StateReason != "" {
|
||||
@@ -153,3 +154,13 @@ func FromAlertsStateToStoppedAlert(firingStates []StateTransition, appURL *url.U
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
// attachImageAnnotations attaches image annotations to the alert.
|
||||
func attachImageAnnotations(image *ngModels.Image, a data.Labels) {
|
||||
if image.Token != "" {
|
||||
a[alertingModels.ImageTokenAnnotation] = image.Token
|
||||
}
|
||||
if image.URL != "" {
|
||||
a[alertingModels.ImageURLAnnotation] = image.URL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,10 +120,10 @@ func Test_StateToPostableAlert(t *testing.T) {
|
||||
require.Equal(t, expected, result.Annotations)
|
||||
})
|
||||
|
||||
t.Run("add __alertImageToken__ if there is an image token", func(t *testing.T) {
|
||||
t.Run("add both annotations if there is an image token and url", func(t *testing.T) {
|
||||
alertState := randomTransition(eval.Normal, tc.state)
|
||||
alertState.Annotations = randomMapOfStrings()
|
||||
alertState.Image = &ngModels.Image{Token: "test_token"}
|
||||
alertState.Image = &ngModels.Image{Token: "test_token", URL: "test_url"}
|
||||
|
||||
result := StateToPostableAlert(alertState, appURL)
|
||||
|
||||
@@ -131,12 +131,17 @@ func Test_StateToPostableAlert(t *testing.T) {
|
||||
for k, v := range alertState.Annotations {
|
||||
expected[k] = v
|
||||
}
|
||||
expected["__alertImageToken__"] = alertState.Image.Token
|
||||
expected[alertingModels.ImageTokenAnnotation] = alertState.Image.Token
|
||||
expected[alertingModels.ImageURLAnnotation] = alertState.Image.URL
|
||||
|
||||
// Sanity check that the annotation is correct.
|
||||
require.Contains(t, result.Annotations[alertingModels.ImageTokenAnnotation], alertState.Image.Token)
|
||||
require.Contains(t, result.Annotations[alertingModels.ImageURLAnnotation], alertState.Image.URL)
|
||||
|
||||
require.Equal(t, expected, result.Annotations)
|
||||
})
|
||||
|
||||
t.Run("don't add __alertImageToken__ if there's no image token", func(t *testing.T) {
|
||||
t.Run("don't add annotations if there's no image token or url", func(t *testing.T) {
|
||||
alertState := randomTransition(eval.Normal, tc.state)
|
||||
alertState.Annotations = randomMapOfStrings()
|
||||
alertState.Image = &ngModels.Image{}
|
||||
|
||||
@@ -16,9 +16,9 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31
|
||||
github.com/grafana/grafana v11.4.0-00010101000000-000000000000+incompatible
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d
|
||||
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d
|
||||
github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250121113133-e747350fee2d
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295
|
||||
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295
|
||||
github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250220154326-6e5de80ef295
|
||||
github.com/stretchr/testify v1.10.0
|
||||
gocloud.dev v0.40.0
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
|
||||
@@ -191,7 +191,7 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 // indirect
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 // indirect
|
||||
@@ -199,9 +199,9 @@ require (
|
||||
github.com/grafana/grafana-aws-sdk v0.31.5 // indirect
|
||||
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6 // indirect
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.266.0 // indirect
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d // indirect
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 // indirect
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.8 // indirect
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d // indirect
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450 // indirect
|
||||
github.com/grafana/otel-profiling-go v0.5.1 // indirect
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect
|
||||
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
|
||||
|
||||
@@ -564,8 +564,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664 h1:avtd90OVg2wMHz5pUsTWypu4rEt5bn5H0kOLXa7xgqc=
|
||||
github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 h1:LGH+tVzHCDrR9hsltmkP4jmNRg5IreQw5CNFbJKlnts=
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 h1:NTMmow+74I3Jb033xhbRgWQS7A//5TDhiM4tl7bsVP4=
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7/go.mod h1:T3X4z0ejGfJOiOmZLFeKCRT/yxWJq/RtclAc/PHj/w4=
|
||||
github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 h1:EokLC5grHwLPs4tXW8T6E8187H1e5G9AP0QQ5B60HbA=
|
||||
@@ -584,12 +584,12 @@ github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6 h1:OfCkitCuomzZKW1WYHrG8MxKwtM
|
||||
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.266.0 h1:YP+iEpXH3HRX9Xo4NHjsrJhN2W7uVTtkLNzMHYbmiLI=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.266.0/go.mod h1:bxkXrBQ4QSmOncsWdIOcpgP+M6wajQNMAPXlbWrqAWY=
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d h1:aBD5kzsIAh50vjNqUkWK9mNpLGIBYAnKkWtUepGNAiQ=
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d/go.mod h1:1sq0guad+G4SUTlBgx7SXfhnzy7D86K/LcVOtiQCiMA=
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 h1:9qOLpC21AmXZqZ6rUhrBWl2mVqS3CzV53pzw0BCuHt0=
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8/go.mod h1:deLQ/ywLvpVGbncRGUA4UDGt8a5Ei9sivOP+x6AQ2ko=
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0=
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.8/go.mod h1:U1ezG/MGaEPoThqsr3lymMPN5yIPdVTJnDZ+wcXT+ao=
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d h1:cYBjuhb3m5oC6Z00Kw8DdySFaNhwb38SMxx0oXnz5vQ=
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c=
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450 h1:wSqgLKFwI7fyeqf3djRXGClBLb/UPjZ4XPm/UsKFDB0=
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450/go.mod h1:HGz9/wKeN6U48g4F8RxHzpsFxJAR9anVltZp07A86UA=
|
||||
github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8=
|
||||
github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg=
|
||||
|
||||
@@ -294,7 +294,7 @@ func (s *Storage) Watch(ctx context.Context, key string, opts storage.ListOption
|
||||
}
|
||||
|
||||
reporter := apierrors.NewClientErrorReporter(500, "WATCH", "")
|
||||
decoder := newStreamDecoder(client, s.newFunc, predicate, s.codec, cancelWatch)
|
||||
decoder := newStreamDecoder(client, s.newFunc, predicate, s.codec, cancelWatch, s.opts.InternalConversion)
|
||||
|
||||
return watch.NewStreamWatcher(decoder, reporter), nil
|
||||
}
|
||||
|
||||
@@ -19,25 +19,33 @@ import (
|
||||
)
|
||||
|
||||
type streamDecoder struct {
|
||||
client resource.ResourceStore_WatchClient
|
||||
newFunc func() runtime.Object
|
||||
predicate storage.SelectionPredicate
|
||||
codec runtime.Codec
|
||||
cancelWatch context.CancelFunc
|
||||
done sync.WaitGroup
|
||||
client resource.ResourceStore_WatchClient
|
||||
newFunc func() runtime.Object
|
||||
predicate storage.SelectionPredicate
|
||||
codec runtime.Codec
|
||||
cancelWatch context.CancelFunc
|
||||
done sync.WaitGroup
|
||||
internalConversion func([]byte, runtime.Object) (runtime.Object, error)
|
||||
}
|
||||
|
||||
func newStreamDecoder(client resource.ResourceStore_WatchClient, newFunc func() runtime.Object, predicate storage.SelectionPredicate, codec runtime.Codec, cancelWatch context.CancelFunc) *streamDecoder {
|
||||
func newStreamDecoder(client resource.ResourceStore_WatchClient, newFunc func() runtime.Object, predicate storage.SelectionPredicate, codec runtime.Codec, cancelWatch context.CancelFunc, internalConversion func([]byte, runtime.Object) (runtime.Object, error)) *streamDecoder {
|
||||
return &streamDecoder{
|
||||
client: client,
|
||||
newFunc: newFunc,
|
||||
predicate: predicate,
|
||||
codec: codec,
|
||||
cancelWatch: cancelWatch,
|
||||
client: client,
|
||||
newFunc: newFunc,
|
||||
predicate: predicate,
|
||||
codec: codec,
|
||||
cancelWatch: cancelWatch,
|
||||
internalConversion: internalConversion,
|
||||
}
|
||||
}
|
||||
func (d *streamDecoder) toObject(w *resource.WatchEvent_Resource) (runtime.Object, error) {
|
||||
obj, _, err := d.codec.Decode(w.Value, nil, d.newFunc())
|
||||
var obj runtime.Object
|
||||
var err error
|
||||
if d.internalConversion != nil {
|
||||
obj, err = d.internalConversion(w.Value, d.newFunc())
|
||||
} else {
|
||||
obj, _, err = d.codec.Decode(w.Value, nil, d.newFunc())
|
||||
}
|
||||
if err == nil {
|
||||
accessor, err := utils.MetaAccessor(obj)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package apistore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
func TestStreamDecoder(t *testing.T) {
|
||||
t.Run("toObject should handle internal conversion", func(t *testing.T) {
|
||||
called := false
|
||||
internalConversion := func(data []byte, obj runtime.Object) (runtime.Object, error) {
|
||||
called = true
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
decoder := &streamDecoder{
|
||||
newFunc: func() runtime.Object { return &unstructured.Unstructured{} },
|
||||
internalConversion: internalConversion,
|
||||
}
|
||||
|
||||
event := &resource.WatchEvent_Resource{
|
||||
Value: []byte("test"),
|
||||
}
|
||||
|
||||
obj, err := decoder.toObject(event)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, obj)
|
||||
require.True(t, called, "internal conversion function should have been called")
|
||||
})
|
||||
}
|
||||
@@ -4,8 +4,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
otgrpc "github.com/opentracing-contrib/go-grpc"
|
||||
"github.com/opentracing/opentracing-go"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
|
||||
"gocloud.dev/blob/fileblob"
|
||||
"google.golang.org/grpc"
|
||||
@@ -13,6 +17,9 @@ import (
|
||||
|
||||
authnlib "github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/authlib/types"
|
||||
"github.com/grafana/dskit/flagext"
|
||||
"github.com/grafana/dskit/grpcclient"
|
||||
"github.com/grafana/dskit/middleware"
|
||||
|
||||
infraDB "github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
@@ -39,6 +46,11 @@ type Options struct {
|
||||
Docs resource.DocumentBuilderSupplier
|
||||
}
|
||||
|
||||
type clientMetrics struct {
|
||||
requestDuration *prometheus.HistogramVec
|
||||
requestRetries *prometheus.CounterVec
|
||||
}
|
||||
|
||||
// This adds a UnifiedStorage client into the wire dependency tree
|
||||
func ProvideUnifiedStorageClient(opts *Options) (resource.ResourceClient, error) {
|
||||
// See: apiserver.ApplyGrafanaConfig(cfg, features, o)
|
||||
@@ -104,11 +116,8 @@ func newClient(opts options.StorageOptions,
|
||||
return nil, fmt.Errorf("expecting address for storage_type: %s", opts.StorageType)
|
||||
}
|
||||
|
||||
// Create a connection to the gRPC server
|
||||
conn, err := grpc.NewClient(opts.Address,
|
||||
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
// Create a connection to the gRPC server.
|
||||
conn, err := GrpcConn(opts.Address, reg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -153,3 +162,67 @@ func newResourceClient(conn *grpc.ClientConn, cfg *setting.Cfg, features feature
|
||||
}
|
||||
return resource.NewRemoteResourceClient(tracer, conn, clientCfgMapping(grpcutils.ReadGrpcClientConfig(cfg)), cfg.Env == setting.Dev)
|
||||
}
|
||||
|
||||
// GrpcConn creates a new gRPC connection to the provided address.
|
||||
func GrpcConn(address string, reg prometheus.Registerer) (*grpc.ClientConn, error) {
|
||||
// This works for now as the Provide function is only called once during startup.
|
||||
// We might eventually want to tight this factory to a struct for more runtime control.
|
||||
metrics := clientMetrics{
|
||||
requestDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "resource_server_client_request_duration_seconds",
|
||||
Help: "Time spent executing requests to the resource server.",
|
||||
Buckets: prometheus.ExponentialBuckets(0.008, 4, 7),
|
||||
}, []string{"operation", "status_code"}),
|
||||
requestRetries: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "resource_server_client_request_retries_total",
|
||||
Help: "Total number of retries for requests to the resource server.",
|
||||
}, []string{"operation"}),
|
||||
}
|
||||
|
||||
// Report gRPC status code errors as labels.
|
||||
unary, stream := instrument(metrics.requestDuration, middleware.ReportGRPCStatusOption)
|
||||
|
||||
// Add middleware to retry on transient connection issues. Note that
|
||||
// we do not implement it for streams, as we don't currently use streams.
|
||||
retryCfg := retryConfig{
|
||||
Max: 3,
|
||||
Backoff: time.Second,
|
||||
BackoffJitter: 0.5,
|
||||
}
|
||||
unary = append(unary, unaryRetryInterceptor(retryCfg))
|
||||
unary = append(unary, unaryRetryInstrument(metrics.requestRetries))
|
||||
|
||||
cfg := grpcclient.Config{}
|
||||
// Set the defaults that are normally set by Config.RegisterFlags.
|
||||
flagext.DefaultValues(&cfg)
|
||||
|
||||
opts, err := cfg.DialOption(unary, stream)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not instrument grpc client: %w", err)
|
||||
}
|
||||
|
||||
opts = append(opts, grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
|
||||
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
|
||||
// Use round_robin to balances requests more evenly over the available Storage server.
|
||||
opts = append(opts, grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`))
|
||||
|
||||
// Disable looking up service config from TXT DNS records.
|
||||
// This reduces the number of requests made to the DNS servers.
|
||||
opts = append(opts, grpc.WithDisableServiceConfig())
|
||||
|
||||
// Create a connection to the gRPC server
|
||||
return grpc.NewClient(address, opts...)
|
||||
}
|
||||
|
||||
// instrument is the same as grpcclient.Instrument but without the middleware.ClientUserHeaderInterceptor
|
||||
// and middleware.StreamClientUserHeaderInterceptor as we don't need them.
|
||||
func instrument(requestDuration *prometheus.HistogramVec, instrumentationLabelOptions ...middleware.InstrumentationOption) ([]grpc.UnaryClientInterceptor, []grpc.StreamClientInterceptor) {
|
||||
return []grpc.UnaryClientInterceptor{
|
||||
otgrpc.OpenTracingClientInterceptor(opentracing.GlobalTracer()),
|
||||
middleware.UnaryClientInstrumentInterceptor(requestDuration, instrumentationLabelOptions...),
|
||||
}, []grpc.StreamClientInterceptor{
|
||||
otgrpc.OpenTracingStreamClientInterceptor(opentracing.GlobalTracer()),
|
||||
middleware.StreamClientInstrumentInterceptor(requestDuration, instrumentationLabelOptions...),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package unified
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
grpc_retry "github.com/grpc-ecosystem/go-grpc-middleware/retry"
|
||||
"github.com/grpc-ecosystem/go-grpc-middleware/util/metautils"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
)
|
||||
|
||||
type retryConfig struct {
|
||||
Max uint
|
||||
Backoff time.Duration
|
||||
BackoffJitter float64
|
||||
}
|
||||
|
||||
// unaryRetryInterceptor creates an interceptor to perform retries for unary methods.
|
||||
//
|
||||
// Note: Retry codes are the same as the default codes.
|
||||
//
|
||||
// From go-grpc-middleware/interceptors/retry/options.go:
|
||||
// `ResourceExhausted` means that the user quota, e.g. per-RPC limits, have been reached.
|
||||
// `Unavailable` means that system is currently unavailable and the client should retry again.
|
||||
func unaryRetryInterceptor(cfg retryConfig) grpc.UnaryClientInterceptor {
|
||||
return grpc_retry.UnaryClientInterceptor(
|
||||
grpc_retry.WithMax(cfg.Max),
|
||||
grpc_retry.WithBackoff(grpc_retry.BackoffExponentialWithJitter(cfg.Backoff, cfg.BackoffJitter)),
|
||||
grpc_retry.WithCodes(codes.ResourceExhausted, codes.Unavailable),
|
||||
)
|
||||
}
|
||||
|
||||
// unaryRetryInstrument creates an interceptor to count and log retry attempts.
|
||||
func unaryRetryInstrument(metric *prometheus.CounterVec) grpc.UnaryClientInterceptor {
|
||||
return func(ctx context.Context, method string, req, resp interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||
// We can tell if a call is a retry by checking the retry attempt metadata.
|
||||
attempt, err := strconv.Atoi(metautils.ExtractOutgoing(ctx).Get(grpc_retry.AttemptMetadataKey))
|
||||
if err == nil && attempt > 0 {
|
||||
metric.WithLabelValues(method).Inc()
|
||||
}
|
||||
return invoker(ctx, method, req, resp, cc, opts...)
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,10 @@ func (s *cdkBackend) WriteEvent(ctx context.Context, event WriteEvent) (rv int64
|
||||
if s.stream != nil {
|
||||
go func() {
|
||||
write := &WrittenEvent{
|
||||
WriteEvent: event,
|
||||
Type: event.Type,
|
||||
Key: event.Key,
|
||||
PreviousRV: event.PreviousRV,
|
||||
Value: event.Value,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
ResourceVersion: rv,
|
||||
}
|
||||
|
||||
@@ -21,18 +21,24 @@ type WriteEvent struct {
|
||||
ObjectOld utils.GrafanaMetaAccessor
|
||||
}
|
||||
|
||||
// WriteEvents after they include a resource version
|
||||
// WrittenEvent is a WriteEvent reported with a resource version.
|
||||
type WrittenEvent struct {
|
||||
WriteEvent
|
||||
Type WatchEvent_Type
|
||||
Key *ResourceKey
|
||||
PreviousRV int64
|
||||
|
||||
// The json payload (without resourceVersion)
|
||||
Value []byte
|
||||
|
||||
// Metadata
|
||||
Folder string
|
||||
|
||||
// The resource version
|
||||
// The resource version.
|
||||
ResourceVersion int64
|
||||
|
||||
// Timestamp when the event is created
|
||||
Timestamp int64
|
||||
}
|
||||
|
||||
// A function to write events
|
||||
// EventAppender is a function to write events.
|
||||
type EventAppender = func(context.Context, *WriteEvent) (int64, error)
|
||||
|
||||
@@ -16,8 +16,8 @@ require (
|
||||
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040
|
||||
github.com/grafana/grafana v11.4.0-00010101000000-000000000000+incompatible
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.266.0
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d
|
||||
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d // indirect
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295
|
||||
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
@@ -118,7 +118,7 @@ require (
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
github.com/grafana/grafana-app-sdk/logging v0.30.0 // indirect
|
||||
github.com/grafana/grafana-aws-sdk v0.31.5 // indirect
|
||||
|
||||
@@ -405,8 +405,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
|
||||
github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664 h1:avtd90OVg2wMHz5pUsTWypu4rEt5bn5H0kOLXa7xgqc=
|
||||
github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 h1:LGH+tVzHCDrR9hsltmkP4jmNRg5IreQw5CNFbJKlnts=
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 h1:NTMmow+74I3Jb033xhbRgWQS7A//5TDhiM4tl7bsVP4=
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7/go.mod h1:T3X4z0ejGfJOiOmZLFeKCRT/yxWJq/RtclAc/PHj/w4=
|
||||
github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 h1:EokLC5grHwLPs4tXW8T6E8187H1e5G9AP0QQ5B60HbA=
|
||||
|
||||
@@ -851,17 +851,15 @@ func (b *backend) poll(ctx context.Context, grp string, res string, since int64,
|
||||
prevRV = new(int64)
|
||||
}
|
||||
stream <- &resource.WrittenEvent{
|
||||
WriteEvent: resource.WriteEvent{
|
||||
Value: rec.Value,
|
||||
Key: &resource.ResourceKey{
|
||||
Namespace: rec.Key.Namespace,
|
||||
Group: rec.Key.Group,
|
||||
Resource: rec.Key.Resource,
|
||||
Name: rec.Key.Name,
|
||||
},
|
||||
Type: resource.WatchEvent_Type(rec.Action),
|
||||
PreviousRV: *prevRV,
|
||||
Value: rec.Value,
|
||||
Key: &resource.ResourceKey{
|
||||
Namespace: rec.Key.Namespace,
|
||||
Group: rec.Key.Group,
|
||||
Resource: rec.Key.Resource,
|
||||
Name: rec.Key.Name,
|
||||
},
|
||||
Type: resource.WatchEvent_Type(rec.Action),
|
||||
PreviousRV: *prevRV,
|
||||
Folder: rec.Folder,
|
||||
ResourceVersion: rec.ResourceVersion,
|
||||
// Timestamp: , // TODO: add timestamp
|
||||
|
||||
@@ -8,21 +8,19 @@ import (
|
||||
"github.com/go-jose/go-jose/v3/jwt"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
"github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/authlib/types"
|
||||
"github.com/grafana/dskit/services"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
infraDB "github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/unified"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
|
||||
unitest "github.com/grafana/grafana/pkg/storage/unified/testing"
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
@@ -31,451 +29,22 @@ func TestMain(m *testing.M) {
|
||||
testsuite.Run(m)
|
||||
}
|
||||
|
||||
func newServer(t *testing.T, cfg *setting.Cfg) (sql.Backend, resource.ResourceServer) {
|
||||
t.Helper()
|
||||
if cfg == nil {
|
||||
cfg = setting.NewCfg()
|
||||
}
|
||||
|
||||
dbstore := infraDB.InitTestDB(t)
|
||||
|
||||
eDB, err := dbimpl.ProvideResourceDB(dbstore, cfg, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, eDB)
|
||||
|
||||
ret, err := sql.NewBackend(sql.BackendOptions{
|
||||
DBProvider: eDB,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ret)
|
||||
|
||||
err = ret.Init(testutil.NewDefaultTestContext(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
server, err := resource.NewResourceServer(resource.ResourceServerOptions{
|
||||
Backend: ret,
|
||||
Diagnostics: ret,
|
||||
Lifecycle: ret,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, server)
|
||||
|
||||
return ret, server
|
||||
}
|
||||
|
||||
func TestIntegrationBackendHappyPath(t *testing.T) {
|
||||
// if infraDB.IsTestDbSQLite() {
|
||||
// t.Skip("TODO: test blocking, skipping to unblock Enterprise until we fix this")
|
||||
// }
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := types.WithAuthInfo(context.Background(), authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
|
||||
Claims: jwt.Claims{
|
||||
Subject: "testuser",
|
||||
},
|
||||
Rest: authn.AccessTokenClaims{},
|
||||
}))
|
||||
|
||||
backend, server := newServer(t, nil)
|
||||
|
||||
stream, err := backend.WatchWriteEvents(context.Background()) // Using a different context to avoid canceling the stream after the DefaultContextTimeout
|
||||
require.NoError(t, err)
|
||||
var rv1, rv2, rv3, rv4, rv5 int64
|
||||
|
||||
t.Run("Add 3 resources", func(t *testing.T) {
|
||||
rv1, err = writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED)
|
||||
// TestStorageBackend is a test for the StorageBackend interface.
|
||||
func TestIntegrationSQLStorageBackend(t *testing.T) {
|
||||
unitest.RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
|
||||
dbstore := infraDB.InitTestDB(t)
|
||||
eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv1, int64(0))
|
||||
require.NotNil(t, eDB)
|
||||
|
||||
rv2, err = writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv2, rv1)
|
||||
|
||||
rv3, err = writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv3, rv2)
|
||||
|
||||
stats, err := backend.GetResourceStats(ctx, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stats, 1)
|
||||
require.Equal(t, int64(3), stats[0].Count)
|
||||
require.Equal(t, rv3, stats[0].ResourceVersion)
|
||||
})
|
||||
|
||||
t.Run("Update item2", func(t *testing.T) {
|
||||
rv4, err = writeEvent(ctx, backend, "item2", resource.WatchEvent_MODIFIED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv4, rv3)
|
||||
})
|
||||
|
||||
t.Run("Delete item1", func(t *testing.T) {
|
||||
rv5, err = writeEvent(ctx, backend, "item1", resource.WatchEvent_DELETED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv5, rv4)
|
||||
})
|
||||
|
||||
t.Run("Read latest item 2", func(t *testing.T) {
|
||||
resp := backend.ReadResource(ctx, &resource.ReadRequest{Key: resourceKey("item2")})
|
||||
require.Nil(t, resp.Error)
|
||||
require.Equal(t, rv4, resp.ResourceVersion)
|
||||
require.Equal(t, "item2 MODIFIED", string(resp.Value))
|
||||
require.Equal(t, "folderuid", resp.Folder)
|
||||
})
|
||||
|
||||
t.Run("Read early version of item2", func(t *testing.T) {
|
||||
resp := backend.ReadResource(ctx, &resource.ReadRequest{
|
||||
Key: resourceKey("item2"),
|
||||
ResourceVersion: rv3, // item2 was created at rv2 and updated at rv4
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
require.Equal(t, rv2, resp.ResourceVersion)
|
||||
require.Equal(t, "item2 ADDED", string(resp.Value))
|
||||
})
|
||||
|
||||
t.Run("PrepareList latest", func(t *testing.T) {
|
||||
resp, err := server.List(ctx, &resource.ListRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Namespace: "namespace",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
},
|
||||
},
|
||||
backend, err := sql.NewBackend(sql.BackendOptions{
|
||||
DBProvider: eDB,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, resp.Error)
|
||||
require.Len(t, resp.Items, 2)
|
||||
require.Equal(t, "item2 MODIFIED", string(resp.Items[0].Value))
|
||||
require.Equal(t, "item3 ADDED", string(resp.Items[1].Value))
|
||||
require.Equal(t, rv5, resp.ResourceVersion)
|
||||
})
|
||||
|
||||
t.Run("Watch events", func(t *testing.T) {
|
||||
event := <-stream
|
||||
require.Equal(t, "item1", event.Key.Name)
|
||||
require.Equal(t, rv1, event.ResourceVersion)
|
||||
require.Equal(t, resource.WatchEvent_ADDED, event.Type)
|
||||
require.Equal(t, "folderuid", event.Folder)
|
||||
|
||||
event = <-stream
|
||||
require.Equal(t, "item2", event.Key.Name)
|
||||
require.Equal(t, rv2, event.ResourceVersion)
|
||||
require.Equal(t, resource.WatchEvent_ADDED, event.Type)
|
||||
require.Equal(t, "folderuid", event.Folder)
|
||||
|
||||
event = <-stream
|
||||
require.Equal(t, "item3", event.Key.Name)
|
||||
require.Equal(t, rv3, event.ResourceVersion)
|
||||
require.Equal(t, resource.WatchEvent_ADDED, event.Type)
|
||||
|
||||
event = <-stream
|
||||
require.Equal(t, "item2", event.Key.Name)
|
||||
require.Equal(t, rv4, event.ResourceVersion)
|
||||
require.Equal(t, resource.WatchEvent_MODIFIED, event.Type)
|
||||
|
||||
event = <-stream
|
||||
require.Equal(t, "item1", event.Key.Name)
|
||||
require.Equal(t, rv5, event.ResourceVersion)
|
||||
require.Equal(t, resource.WatchEvent_DELETED, event.Type)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationBackendWatchWriteEventsFromLastest(t *testing.T) {
|
||||
if infraDB.IsTestDbSQLite() {
|
||||
t.Skip("TODO: test blocking, skipping to unblock Enterprise until we fix this")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second))
|
||||
backend, _ := newServer(t, nil)
|
||||
|
||||
// Create a few resources before initing the watch
|
||||
_, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Start the watch
|
||||
stream, err := backend.WatchWriteEvents(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create one more event
|
||||
_, err = writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "item2", (<-stream).Key.Name)
|
||||
}
|
||||
|
||||
func TestIntegrationBackendList(t *testing.T) {
|
||||
if infraDB.IsTestDbSQLite() {
|
||||
t.Skip("TODO: test blocking, skipping to unblock Enterprise until we fix this")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second))
|
||||
backend, server := newServer(t, nil)
|
||||
|
||||
// Create a few resources before starting the watch
|
||||
rv1, _ := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED)
|
||||
require.Greater(t, rv1, int64(0))
|
||||
rv2, _ := writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED) // rv=2 - will be modified at rv=6
|
||||
require.Greater(t, rv2, rv1)
|
||||
rv3, _ := writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED) // rv=3 - will be deleted at rv=7
|
||||
require.Greater(t, rv3, rv2)
|
||||
rv4, _ := writeEvent(ctx, backend, "item4", resource.WatchEvent_ADDED)
|
||||
require.Greater(t, rv4, rv3)
|
||||
rv5, _ := writeEvent(ctx, backend, "item5", resource.WatchEvent_ADDED)
|
||||
require.Greater(t, rv5, rv4)
|
||||
rv6, _ := writeEvent(ctx, backend, "item2", resource.WatchEvent_MODIFIED)
|
||||
require.Greater(t, rv6, rv5)
|
||||
rv7, _ := writeEvent(ctx, backend, "item3", resource.WatchEvent_DELETED)
|
||||
require.Greater(t, rv7, rv6)
|
||||
rv8, _ := writeEvent(ctx, backend, "item6", resource.WatchEvent_ADDED)
|
||||
require.Greater(t, rv8, rv7)
|
||||
|
||||
t.Run("fetch all latest", func(t *testing.T) {
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NotNil(t, backend)
|
||||
err = backend.Init(testutil.NewDefaultTestContext(t))
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 5)
|
||||
// should be sorted by key ASC
|
||||
require.Equal(t, "item1 ADDED", string(res.Items[0].Value))
|
||||
require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value))
|
||||
require.Equal(t, "item4 ADDED", string(res.Items[2].Value))
|
||||
require.Equal(t, "item5 ADDED", string(res.Items[3].Value))
|
||||
require.Equal(t, "item6 ADDED", string(res.Items[4].Value))
|
||||
|
||||
require.Empty(t, res.NextPageToken)
|
||||
})
|
||||
|
||||
t.Run("list latest first page ", func(t *testing.T) {
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
Limit: 3,
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 3)
|
||||
continueToken, err := sql.GetContinueToken(res.NextPageToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "item1 ADDED", string(res.Items[0].Value))
|
||||
require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value))
|
||||
require.Equal(t, "item4 ADDED", string(res.Items[2].Value))
|
||||
require.Equal(t, rv8, continueToken.ResourceVersion)
|
||||
})
|
||||
|
||||
t.Run("list at revision", func(t *testing.T) {
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
ResourceVersion: rv4,
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 4)
|
||||
require.Equal(t, "item1 ADDED", string(res.Items[0].Value))
|
||||
require.Equal(t, "item2 ADDED", string(res.Items[1].Value))
|
||||
require.Equal(t, "item3 ADDED", string(res.Items[2].Value))
|
||||
require.Equal(t, "item4 ADDED", string(res.Items[3].Value))
|
||||
require.Empty(t, res.NextPageToken)
|
||||
})
|
||||
|
||||
t.Run("fetch first page at revision with limit", func(t *testing.T) {
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
Limit: 3,
|
||||
ResourceVersion: rv7,
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 3)
|
||||
t.Log(res.Items)
|
||||
require.Equal(t, "item1 ADDED", string(res.Items[0].Value))
|
||||
require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value))
|
||||
require.Equal(t, "item4 ADDED", string(res.Items[2].Value))
|
||||
|
||||
continueToken, err := sql.GetContinueToken(res.NextPageToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rv7, continueToken.ResourceVersion)
|
||||
})
|
||||
|
||||
t.Run("fetch second page at revision", func(t *testing.T) {
|
||||
continueToken := &sql.ContinueToken{
|
||||
ResourceVersion: rv8,
|
||||
StartOffset: 2,
|
||||
}
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
NextPageToken: continueToken.String(),
|
||||
Limit: 2,
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 2)
|
||||
t.Log(res.Items)
|
||||
require.Equal(t, "item4 ADDED", string(res.Items[0].Value))
|
||||
require.Equal(t, "item5 ADDED", string(res.Items[1].Value))
|
||||
|
||||
continueToken, err = sql.GetContinueToken(res.NextPageToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rv8, continueToken.ResourceVersion)
|
||||
require.Equal(t, int64(4), continueToken.StartOffset)
|
||||
})
|
||||
|
||||
// add 5 events for item1 - should be saved to history
|
||||
rvHistory1, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rvHistory1, rv1)
|
||||
rvHistory2, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rvHistory2, rvHistory1)
|
||||
rvHistory3, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rvHistory3, rvHistory2)
|
||||
rvHistory4, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rvHistory4, rvHistory3)
|
||||
rvHistory5, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rvHistory5, rvHistory4)
|
||||
|
||||
t.Run("fetch first history page at revision with limit", func(t *testing.T) {
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
Limit: 3,
|
||||
Source: resource.ListRequest_HISTORY,
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Namespace: "namespace",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
Name: "item1",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 3)
|
||||
t.Log(res.Items)
|
||||
// should be in desc order, so the newest RVs are returned first
|
||||
require.Equal(t, "item1 MODIFIED", string(res.Items[0].Value))
|
||||
require.Equal(t, rvHistory5, res.Items[0].ResourceVersion)
|
||||
require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value))
|
||||
require.Equal(t, rvHistory4, res.Items[1].ResourceVersion)
|
||||
require.Equal(t, "item1 MODIFIED", string(res.Items[2].Value))
|
||||
require.Equal(t, rvHistory3, res.Items[2].ResourceVersion)
|
||||
|
||||
continueToken, err := sql.GetContinueToken(res.NextPageToken)
|
||||
require.NoError(t, err)
|
||||
// should return the furthest back RV as the next page token
|
||||
require.Equal(t, rvHistory3, continueToken.ResourceVersion)
|
||||
})
|
||||
|
||||
t.Run("fetch second page of history at revision", func(t *testing.T) {
|
||||
continueToken := &sql.ContinueToken{
|
||||
ResourceVersion: rvHistory3,
|
||||
StartOffset: 2,
|
||||
}
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
NextPageToken: continueToken.String(),
|
||||
Limit: 2,
|
||||
Source: resource.ListRequest_HISTORY,
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Namespace: "namespace",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
Name: "item1",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 2)
|
||||
t.Log(res.Items)
|
||||
require.Equal(t, "item1 MODIFIED", string(res.Items[0].Value))
|
||||
require.Equal(t, rvHistory2, res.Items[0].ResourceVersion)
|
||||
require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value))
|
||||
require.Equal(t, rvHistory1, res.Items[1].ResourceVersion)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationBlobSupport(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second))
|
||||
backend, server := newServer(t, nil)
|
||||
store, ok := backend.(resource.BlobSupport)
|
||||
require.True(t, ok)
|
||||
|
||||
t.Run("put and fetch blob", func(t *testing.T) {
|
||||
key := &resource.ResourceKey{
|
||||
Namespace: "ns",
|
||||
Group: "g",
|
||||
Resource: "r",
|
||||
Name: "n",
|
||||
}
|
||||
|
||||
b1, err := server.PutBlob(ctx, &resource.PutBlobRequest{
|
||||
Resource: key,
|
||||
Method: resource.PutBlobRequest_GRPC,
|
||||
ContentType: "plain/text",
|
||||
Value: []byte("hello 11111"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, b1.Error)
|
||||
require.Equal(t, "c894ae57bd227b8f8c63f38a2ddf458b", b1.Hash)
|
||||
|
||||
b2, err := server.PutBlob(ctx, &resource.PutBlobRequest{
|
||||
Resource: key,
|
||||
Method: resource.PutBlobRequest_GRPC,
|
||||
ContentType: "plain/text",
|
||||
Value: []byte("hello 22222"), // the most recent
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, b2.Error)
|
||||
require.Equal(t, "b0da48de4ff92e0ad0d836de4d746937", b2.Hash)
|
||||
|
||||
// Check that we can still access both values
|
||||
found, err := store.GetResourceBlob(ctx, key, &utils.BlobInfo{UID: b1.Uid}, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("hello 11111"), found.Value)
|
||||
|
||||
found, err = store.GetResourceBlob(ctx, key, &utils.BlobInfo{UID: b2.Uid}, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("hello 22222"), found.Value)
|
||||
return backend
|
||||
})
|
||||
}
|
||||
|
||||
@@ -510,7 +79,7 @@ func TestClientServer(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Create a client", func(t *testing.T) {
|
||||
conn, err := grpc.NewClient(svc.GetAddress(), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
conn, err := unified.GrpcConn(svc.GetAddress(), prometheus.NewPedanticRegistry())
|
||||
require.NoError(t, err)
|
||||
client, err = resource.NewRemoteResourceClient(tracing.NewNoopTracerService(), conn, authn.GrpcClientConfig{}, true)
|
||||
require.NoError(t, err)
|
||||
@@ -550,28 +119,6 @@ func TestClientServer(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func writeEvent(ctx context.Context, store sql.Backend, name string, action resource.WatchEvent_Type) (int64, error) {
|
||||
res := &unstructured.Unstructured{
|
||||
Object: map[string]any{},
|
||||
}
|
||||
meta, err := utils.MetaAccessor(res)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
meta.SetFolder("folderuid")
|
||||
return store.WriteEvent(ctx, resource.WriteEvent{
|
||||
Type: action,
|
||||
Value: []byte(name + " " + resource.WatchEvent_Type_name[int32(action)]),
|
||||
Key: &resource.ResourceKey{
|
||||
Namespace: "namespace",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
Name: name,
|
||||
},
|
||||
Object: meta,
|
||||
})
|
||||
}
|
||||
|
||||
func resourceKey(name string) *resource.ResourceKey {
|
||||
return &resource.ResourceKey{
|
||||
Namespace: "namespace",
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v3/jwt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
"github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
infraDB "github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
|
||||
type NewBackendFunc func(ctx context.Context) resource.StorageBackend
|
||||
|
||||
// TestCase defines a test case for the storage backend
|
||||
type TestCase struct {
|
||||
name string
|
||||
fn func(*testing.T, resource.StorageBackend)
|
||||
}
|
||||
|
||||
// StorageBackendTestSuite defines the test suite for storage backend
|
||||
type StorageBackendTestSuite struct {
|
||||
newBackend NewBackendFunc
|
||||
cases []TestCase
|
||||
}
|
||||
|
||||
// NewStorageBackendTestSuite creates a new test suite
|
||||
func NewStorageBackendTestSuite(newBackend NewBackendFunc) *StorageBackendTestSuite {
|
||||
return &StorageBackendTestSuite{
|
||||
newBackend: newBackend,
|
||||
cases: []TestCase{
|
||||
{"happy path", runTestIntegrationBackendHappyPath},
|
||||
{"watch write events from latest", runTestIntegrationBackendWatchWriteEventsFromLastest},
|
||||
{"list", runTestIntegrationBackendList},
|
||||
{"blob support", runTestIntegrationBlobSupport},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes all test cases in the suite
|
||||
func (s *StorageBackendTestSuite) Run(t *testing.T) {
|
||||
for _, tc := range s.cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.fn(t, s.newBackend(context.Background()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RunStorageBackendTest runs the storage backend test suite
|
||||
func RunStorageBackendTest(t *testing.T, newBackend NewBackendFunc) {
|
||||
suite := NewStorageBackendTestSuite(newBackend)
|
||||
suite.Run(t)
|
||||
}
|
||||
|
||||
func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBackend) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := types.WithAuthInfo(context.Background(), authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
|
||||
Claims: jwt.Claims{
|
||||
Subject: "testuser",
|
||||
},
|
||||
Rest: authn.AccessTokenClaims{},
|
||||
}))
|
||||
|
||||
server := newServer(t, backend)
|
||||
|
||||
stream, err := backend.WatchWriteEvents(context.Background()) // Using a different context to avoid canceling the stream after the DefaultContextTimeout
|
||||
require.NoError(t, err)
|
||||
var rv1, rv2, rv3, rv4, rv5 int64
|
||||
|
||||
t.Run("Add 3 resources", func(t *testing.T) {
|
||||
rv1, err = writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv1, int64(0))
|
||||
|
||||
rv2, err = writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv2, rv1)
|
||||
|
||||
rv3, err = writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv3, rv2)
|
||||
|
||||
stats, err := backend.GetResourceStats(ctx, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stats, 1)
|
||||
require.Equal(t, int64(3), stats[0].Count)
|
||||
require.Equal(t, rv3, stats[0].ResourceVersion)
|
||||
})
|
||||
|
||||
t.Run("Update item2", func(t *testing.T) {
|
||||
rv4, err = writeEvent(ctx, backend, "item2", resource.WatchEvent_MODIFIED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv4, rv3)
|
||||
})
|
||||
|
||||
t.Run("Delete item1", func(t *testing.T) {
|
||||
rv5, err = writeEvent(ctx, backend, "item1", resource.WatchEvent_DELETED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv5, rv4)
|
||||
})
|
||||
|
||||
t.Run("Read latest item 2", func(t *testing.T) {
|
||||
resp := backend.ReadResource(ctx, &resource.ReadRequest{Key: resourceKey("item2")})
|
||||
require.Nil(t, resp.Error)
|
||||
require.Equal(t, rv4, resp.ResourceVersion)
|
||||
require.Equal(t, "item2 MODIFIED", string(resp.Value))
|
||||
require.Equal(t, "folderuid", resp.Folder)
|
||||
})
|
||||
|
||||
t.Run("Read early version of item2", func(t *testing.T) {
|
||||
resp := backend.ReadResource(ctx, &resource.ReadRequest{
|
||||
Key: resourceKey("item2"),
|
||||
ResourceVersion: rv3, // item2 was created at rv2 and updated at rv4
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
require.Equal(t, rv2, resp.ResourceVersion)
|
||||
require.Equal(t, "item2 ADDED", string(resp.Value))
|
||||
})
|
||||
|
||||
t.Run("PrepareList latest", func(t *testing.T) {
|
||||
resp, err := server.List(ctx, &resource.ListRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Namespace: "namespace",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, resp.Error)
|
||||
require.Len(t, resp.Items, 2)
|
||||
require.Equal(t, "item2 MODIFIED", string(resp.Items[0].Value))
|
||||
require.Equal(t, "item3 ADDED", string(resp.Items[1].Value))
|
||||
require.Equal(t, rv5, resp.ResourceVersion)
|
||||
})
|
||||
|
||||
t.Run("Watch events", func(t *testing.T) {
|
||||
event := <-stream
|
||||
require.Equal(t, "item1", event.Key.Name)
|
||||
require.Equal(t, rv1, event.ResourceVersion)
|
||||
require.Equal(t, resource.WatchEvent_ADDED, event.Type)
|
||||
require.Equal(t, "folderuid", event.Folder)
|
||||
|
||||
event = <-stream
|
||||
require.Equal(t, "item2", event.Key.Name)
|
||||
require.Equal(t, rv2, event.ResourceVersion)
|
||||
require.Equal(t, resource.WatchEvent_ADDED, event.Type)
|
||||
require.Equal(t, "folderuid", event.Folder)
|
||||
|
||||
event = <-stream
|
||||
require.Equal(t, "item3", event.Key.Name)
|
||||
require.Equal(t, rv3, event.ResourceVersion)
|
||||
require.Equal(t, resource.WatchEvent_ADDED, event.Type)
|
||||
|
||||
event = <-stream
|
||||
require.Equal(t, "item2", event.Key.Name)
|
||||
require.Equal(t, rv4, event.ResourceVersion)
|
||||
require.Equal(t, resource.WatchEvent_MODIFIED, event.Type)
|
||||
|
||||
event = <-stream
|
||||
require.Equal(t, "item1", event.Key.Name)
|
||||
require.Equal(t, rv5, event.ResourceVersion)
|
||||
require.Equal(t, resource.WatchEvent_DELETED, event.Type)
|
||||
})
|
||||
}
|
||||
|
||||
func runTestIntegrationBackendWatchWriteEventsFromLastest(t *testing.T, backend resource.StorageBackend) {
|
||||
if infraDB.IsTestDbSQLite() {
|
||||
t.Skip("TODO: test blocking, skipping to unblock Enterprise until we fix this")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second))
|
||||
|
||||
// Create a few resources before initing the watch
|
||||
_, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Start the watch
|
||||
stream, err := backend.WatchWriteEvents(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create one more event
|
||||
_, err = writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "item2", (<-stream).Key.Name)
|
||||
}
|
||||
|
||||
func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend) {
|
||||
if infraDB.IsTestDbSQLite() {
|
||||
t.Skip("TODO: test blocking, skipping to unblock Enterprise until we fix this")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second))
|
||||
server := newServer(t, backend)
|
||||
|
||||
// Create a few resources before starting the watch
|
||||
rv1, _ := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED)
|
||||
require.Greater(t, rv1, int64(0))
|
||||
rv2, _ := writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED) // rv=2 - will be modified at rv=6
|
||||
require.Greater(t, rv2, rv1)
|
||||
rv3, _ := writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED) // rv=3 - will be deleted at rv=7
|
||||
require.Greater(t, rv3, rv2)
|
||||
rv4, _ := writeEvent(ctx, backend, "item4", resource.WatchEvent_ADDED)
|
||||
require.Greater(t, rv4, rv3)
|
||||
rv5, _ := writeEvent(ctx, backend, "item5", resource.WatchEvent_ADDED)
|
||||
require.Greater(t, rv5, rv4)
|
||||
rv6, _ := writeEvent(ctx, backend, "item2", resource.WatchEvent_MODIFIED)
|
||||
require.Greater(t, rv6, rv5)
|
||||
rv7, _ := writeEvent(ctx, backend, "item3", resource.WatchEvent_DELETED)
|
||||
require.Greater(t, rv7, rv6)
|
||||
rv8, _ := writeEvent(ctx, backend, "item6", resource.WatchEvent_ADDED)
|
||||
require.Greater(t, rv8, rv7)
|
||||
|
||||
t.Run("fetch all latest", func(t *testing.T) {
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 5)
|
||||
// should be sorted by key ASC
|
||||
require.Equal(t, "item1 ADDED", string(res.Items[0].Value))
|
||||
require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value))
|
||||
require.Equal(t, "item4 ADDED", string(res.Items[2].Value))
|
||||
require.Equal(t, "item5 ADDED", string(res.Items[3].Value))
|
||||
require.Equal(t, "item6 ADDED", string(res.Items[4].Value))
|
||||
|
||||
require.Empty(t, res.NextPageToken)
|
||||
})
|
||||
|
||||
t.Run("list latest first page ", func(t *testing.T) {
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
Limit: 3,
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 3)
|
||||
continueToken, err := sql.GetContinueToken(res.NextPageToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "item1 ADDED", string(res.Items[0].Value))
|
||||
require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value))
|
||||
require.Equal(t, "item4 ADDED", string(res.Items[2].Value))
|
||||
require.Equal(t, rv8, continueToken.ResourceVersion)
|
||||
})
|
||||
|
||||
t.Run("list at revision", func(t *testing.T) {
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
ResourceVersion: rv4,
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 4)
|
||||
require.Equal(t, "item1 ADDED", string(res.Items[0].Value))
|
||||
require.Equal(t, "item2 ADDED", string(res.Items[1].Value))
|
||||
require.Equal(t, "item3 ADDED", string(res.Items[2].Value))
|
||||
require.Equal(t, "item4 ADDED", string(res.Items[3].Value))
|
||||
require.Empty(t, res.NextPageToken)
|
||||
})
|
||||
|
||||
t.Run("fetch first page at revision with limit", func(t *testing.T) {
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
Limit: 3,
|
||||
ResourceVersion: rv7,
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 3)
|
||||
t.Log(res.Items)
|
||||
require.Equal(t, "item1 ADDED", string(res.Items[0].Value))
|
||||
require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value))
|
||||
require.Equal(t, "item4 ADDED", string(res.Items[2].Value))
|
||||
|
||||
continueToken, err := sql.GetContinueToken(res.NextPageToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rv7, continueToken.ResourceVersion)
|
||||
})
|
||||
|
||||
t.Run("fetch second page at revision", func(t *testing.T) {
|
||||
continueToken := &sql.ContinueToken{
|
||||
ResourceVersion: rv8,
|
||||
StartOffset: 2,
|
||||
}
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
NextPageToken: continueToken.String(),
|
||||
Limit: 2,
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 2)
|
||||
t.Log(res.Items)
|
||||
require.Equal(t, "item4 ADDED", string(res.Items[0].Value))
|
||||
require.Equal(t, "item5 ADDED", string(res.Items[1].Value))
|
||||
|
||||
continueToken, err = sql.GetContinueToken(res.NextPageToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rv8, continueToken.ResourceVersion)
|
||||
require.Equal(t, int64(4), continueToken.StartOffset)
|
||||
})
|
||||
|
||||
// add 5 events for item1 - should be saved to history
|
||||
rvHistory1, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rvHistory1, rv1)
|
||||
rvHistory2, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rvHistory2, rvHistory1)
|
||||
rvHistory3, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rvHistory3, rvHistory2)
|
||||
rvHistory4, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rvHistory4, rvHistory3)
|
||||
rvHistory5, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rvHistory5, rvHistory4)
|
||||
|
||||
t.Run("fetch first history page at revision with limit", func(t *testing.T) {
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
Limit: 3,
|
||||
Source: resource.ListRequest_HISTORY,
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Namespace: "namespace",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
Name: "item1",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 3)
|
||||
t.Log(res.Items)
|
||||
// should be in desc order, so the newest RVs are returned first
|
||||
require.Equal(t, "item1 MODIFIED", string(res.Items[0].Value))
|
||||
require.Equal(t, rvHistory5, res.Items[0].ResourceVersion)
|
||||
require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value))
|
||||
require.Equal(t, rvHistory4, res.Items[1].ResourceVersion)
|
||||
require.Equal(t, "item1 MODIFIED", string(res.Items[2].Value))
|
||||
require.Equal(t, rvHistory3, res.Items[2].ResourceVersion)
|
||||
|
||||
continueToken, err := sql.GetContinueToken(res.NextPageToken)
|
||||
require.NoError(t, err)
|
||||
// should return the furthest back RV as the next page token
|
||||
require.Equal(t, rvHistory3, continueToken.ResourceVersion)
|
||||
})
|
||||
|
||||
t.Run("fetch second page of history at revision", func(t *testing.T) {
|
||||
continueToken := &sql.ContinueToken{
|
||||
ResourceVersion: rvHistory3,
|
||||
StartOffset: 2,
|
||||
}
|
||||
res, err := server.List(ctx, &resource.ListRequest{
|
||||
NextPageToken: continueToken.String(),
|
||||
Limit: 2,
|
||||
Source: resource.ListRequest_HISTORY,
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Namespace: "namespace",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
Name: "item1",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, res.Error)
|
||||
require.Len(t, res.Items, 2)
|
||||
t.Log(res.Items)
|
||||
require.Equal(t, "item1 MODIFIED", string(res.Items[0].Value))
|
||||
require.Equal(t, rvHistory2, res.Items[0].ResourceVersion)
|
||||
require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value))
|
||||
require.Equal(t, rvHistory1, res.Items[1].ResourceVersion)
|
||||
})
|
||||
}
|
||||
|
||||
func runTestIntegrationBlobSupport(t *testing.T, backend resource.StorageBackend) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second))
|
||||
server := newServer(t, backend)
|
||||
store, ok := backend.(resource.BlobSupport)
|
||||
require.True(t, ok)
|
||||
|
||||
t.Run("put and fetch blob", func(t *testing.T) {
|
||||
key := &resource.ResourceKey{
|
||||
Namespace: "ns",
|
||||
Group: "g",
|
||||
Resource: "r",
|
||||
Name: "n",
|
||||
}
|
||||
|
||||
b1, err := server.PutBlob(ctx, &resource.PutBlobRequest{
|
||||
Resource: key,
|
||||
Method: resource.PutBlobRequest_GRPC,
|
||||
ContentType: "plain/text",
|
||||
Value: []byte("hello 11111"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, b1.Error)
|
||||
require.Equal(t, "c894ae57bd227b8f8c63f38a2ddf458b", b1.Hash)
|
||||
|
||||
b2, err := server.PutBlob(ctx, &resource.PutBlobRequest{
|
||||
Resource: key,
|
||||
Method: resource.PutBlobRequest_GRPC,
|
||||
ContentType: "plain/text",
|
||||
Value: []byte("hello 22222"), // the most recent
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, b2.Error)
|
||||
require.Equal(t, "b0da48de4ff92e0ad0d836de4d746937", b2.Hash)
|
||||
|
||||
// Check that we can still access both values
|
||||
found, err := store.GetResourceBlob(ctx, key, &utils.BlobInfo{UID: b1.Uid}, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("hello 11111"), found.Value)
|
||||
|
||||
found, err = store.GetResourceBlob(ctx, key, &utils.BlobInfo{UID: b2.Uid}, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("hello 22222"), found.Value)
|
||||
})
|
||||
}
|
||||
|
||||
func writeEvent(ctx context.Context, store resource.StorageBackend, name string, action resource.WatchEvent_Type) (int64, error) {
|
||||
res := &unstructured.Unstructured{
|
||||
Object: map[string]any{},
|
||||
}
|
||||
meta, err := utils.MetaAccessor(res)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
meta.SetFolder("folderuid")
|
||||
return store.WriteEvent(ctx, resource.WriteEvent{
|
||||
Type: action,
|
||||
Value: []byte(name + " " + resource.WatchEvent_Type_name[int32(action)]),
|
||||
Key: &resource.ResourceKey{
|
||||
Namespace: "namespace",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
Name: name,
|
||||
},
|
||||
Object: meta,
|
||||
})
|
||||
}
|
||||
|
||||
func resourceKey(name string) *resource.ResourceKey {
|
||||
return &resource.ResourceKey{
|
||||
Namespace: "namespace",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
||||
func newServer(t *testing.T, b resource.StorageBackend) resource.ResourceServer {
|
||||
t.Helper()
|
||||
|
||||
server, err := resource.NewResourceServer(resource.ResourceServerOptions{
|
||||
Backend: b,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, server)
|
||||
|
||||
return server
|
||||
}
|
||||
@@ -2525,7 +2525,7 @@ var expNonEmailNotifications = map[string][]string{
|
||||
"attachments": [
|
||||
{
|
||||
"title": "Integration Test [FIRING:1] SlackAlert1 (default)",
|
||||
"title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert1/view",
|
||||
"title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert1/view?orgId=1",
|
||||
"text": "Integration Test ",
|
||||
"fallback": "Integration Test [FIRING:1] SlackAlert1 (default)",
|
||||
"footer": "Grafana v",
|
||||
@@ -2545,7 +2545,7 @@ var expNonEmailNotifications = map[string][]string{
|
||||
"attachments": [
|
||||
{
|
||||
"title": "[FIRING:1] SlackAlert2 (default)",
|
||||
"title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert2/view",
|
||||
"title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1",
|
||||
"text": "**Firing**\n\nValue: A=1\nLabels:\n - alertname = SlackAlert2\n - grafana_folder = default\nAnnotations:\nSource: http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=__alert_rule_uid__%%3DUID_SlackAlert2&orgId=1\n",
|
||||
"fallback": "[FIRING:1] SlackAlert2 (default)",
|
||||
"footer": "Grafana v",
|
||||
|
||||
@@ -31,7 +31,10 @@ func Query(ctx context.Context, dsInfo *models.DatasourceInfo, tsdbQuery backend
|
||||
for _, query := range tsdbQuery.Queries {
|
||||
qm, err := getQueryModel(query, timeRange, dsInfo)
|
||||
if err != nil {
|
||||
tRes.Responses[query.RefID] = backend.DataResponse{Error: err}
|
||||
tRes.Responses[query.RefID] = backend.DataResponse{
|
||||
Error: err,
|
||||
ErrorSource: backend.ErrorSourceDownstream,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const (
|
||||
|
||||
var (
|
||||
ErrInvalidHttpMode = errors.New("'httpMode' should be either 'GET' or 'POST'")
|
||||
ErrInvalidUrl = errors.New("URL must contain scheme and host")
|
||||
glog = log.New("tsdb.influx_influxql")
|
||||
)
|
||||
|
||||
@@ -51,13 +52,17 @@ func Query(ctx context.Context, tracer trace.Tracer, dsInfo *models.DatasourceIn
|
||||
reqQuery := req.Queries[idx]
|
||||
query, err := models.QueryParse(reqQuery, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
responseLock.Lock()
|
||||
response.Responses[query.RefID] = backend.DataResponse{
|
||||
Error: err,
|
||||
ErrorSource: backend.ErrorSourceDownstream,
|
||||
}
|
||||
responseLock.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
rawQuery, err := query.Build(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// query.Build() unconditionally returns nil for error.
|
||||
rawQuery, _ := query.Build(req)
|
||||
|
||||
query.RefID = reqQuery.RefID
|
||||
query.RawQuery = rawQuery
|
||||
@@ -68,7 +73,13 @@ func Query(ctx context.Context, tracer trace.Tracer, dsInfo *models.DatasourceIn
|
||||
|
||||
request, err := createRequest(ctx, logger, dsInfo, rawQuery, query.Policy)
|
||||
if err != nil {
|
||||
return err
|
||||
responseLock.Lock()
|
||||
response.Responses[query.RefID] = backend.DataResponse{
|
||||
Error: err,
|
||||
ErrorSource: backend.ErrorSourceDownstream,
|
||||
}
|
||||
responseLock.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := execute(ctx, tracer, dsInfo, logger, query, request, features.IsEnabled(ctx, featuremgmt.FlagInfluxqlStreamingParser))
|
||||
@@ -90,13 +101,15 @@ func Query(ctx context.Context, tracer trace.Tracer, dsInfo *models.DatasourceIn
|
||||
for _, reqQuery := range req.Queries {
|
||||
query, err := models.QueryParse(reqQuery, logger)
|
||||
if err != nil {
|
||||
return &backend.QueryDataResponse{}, err
|
||||
response.Responses[query.RefID] = backend.DataResponse{
|
||||
Error: err,
|
||||
ErrorSource: backend.ErrorSourceDownstream,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
rawQuery, err := query.Build(req)
|
||||
if err != nil {
|
||||
return &backend.QueryDataResponse{}, err
|
||||
}
|
||||
// query.Build() unconditionally returns nil for error.
|
||||
rawQuery, _ := query.Build(req)
|
||||
|
||||
query.RefID = reqQuery.RefID
|
||||
query.RawQuery = rawQuery
|
||||
@@ -107,7 +120,11 @@ func Query(ctx context.Context, tracer trace.Tracer, dsInfo *models.DatasourceIn
|
||||
|
||||
request, err := createRequest(ctx, logger, dsInfo, rawQuery, query.Policy)
|
||||
if err != nil {
|
||||
return &backend.QueryDataResponse{}, err
|
||||
response.Responses[query.RefID] = backend.DataResponse{
|
||||
Error: err,
|
||||
ErrorSource: backend.ErrorSourceDownstream,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := execute(ctx, tracer, dsInfo, logger, query, request, features.IsEnabled(ctx, featuremgmt.FlagInfluxqlStreamingParser))
|
||||
@@ -129,6 +146,13 @@ func createRequest(ctx context.Context, logger log.Logger, dsInfo *models.Dataso
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// It's possible that the configuration is bad, and we'll have a URL
|
||||
// without a scheme or host. This is valid from the PoV of the Go std
|
||||
// library url.Parse(), but not for this data source.
|
||||
if u.Host == "" || u.Scheme == "" {
|
||||
return nil, ErrInvalidUrl
|
||||
}
|
||||
|
||||
u.Path = path.Join(u.Path, "query")
|
||||
httpMode := dsInfo.HTTPMode
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -12,6 +13,10 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidQuery = errors.New("invalid InfluxDB query")
|
||||
)
|
||||
|
||||
type InfluxdbQueryParser struct{}
|
||||
|
||||
func QueryParse(query backend.DataQuery, logger log.Logger) (*Query, error) {
|
||||
@@ -33,17 +38,17 @@ func QueryParse(query backend.DataQuery, logger log.Logger) (*Query, error) {
|
||||
|
||||
tags, err := parseTags(model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.Join(ErrInvalidQuery, err)
|
||||
}
|
||||
|
||||
groupBys, err := parseGroupBy(model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.Join(ErrInvalidQuery, err)
|
||||
}
|
||||
|
||||
selects, err := parseSelects(model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.Join(ErrInvalidQuery, err)
|
||||
}
|
||||
|
||||
interval := query.Interval
|
||||
|
||||
@@ -5,8 +5,9 @@ import { useLocation } from 'react-router-dom-v5-compat';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, NavModelItem, toIconName } from '@grafana/data';
|
||||
import { useStyles2, Text, IconButton, Icon, Stack } from '@grafana/ui';
|
||||
import { useStyles2, Text, IconButton, Icon, Stack, Badge } from '@grafana/ui';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { Indent } from '../../Indent/Indent';
|
||||
|
||||
@@ -35,7 +36,7 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick, onPin, isPi
|
||||
const isActive = link === activeItem || (level === MAX_DEPTH && hasActiveChild);
|
||||
const [sectionExpanded, setSectionExpanded] = useLocalStorage(
|
||||
`grafana.navigation.expanded[${link.text}]`,
|
||||
Boolean(hasActiveChild)
|
||||
Boolean(hasActiveChild || link.isNew)
|
||||
);
|
||||
const showExpandButton = level < MAX_DEPTH && Boolean(linkHasChildren(link) || link.emptyMessage);
|
||||
const item = useRef<HTMLLIElement>(null);
|
||||
@@ -107,6 +108,7 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick, onPin, isPi
|
||||
>
|
||||
{level === 0 && iconElement && <FeatureHighlightWrapper>{iconElement}</FeatureHighlightWrapper>}
|
||||
<Text truncate>{link.text}</Text>
|
||||
{link.isNew && <Badge text={t('navigation.megamenu.new', 'New!')} color={'blue'} />}
|
||||
</div>
|
||||
</MegaMenuItemText>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,7 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
|
||||
'&:hover, &:focus-visible': {
|
||||
'&:hover span, &:focus-visible span': {
|
||||
color: theme.colors.text.primary,
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
|
||||
@@ -61,6 +61,8 @@ export function getNavTitle(navId: string | undefined) {
|
||||
return t('nav.scenes.title', 'Scenes');
|
||||
case 'explore':
|
||||
return t('nav.explore.title', 'Explore');
|
||||
case 'drilldown':
|
||||
return t('nav.drilldown.title', 'Drilldown');
|
||||
case 'alerting':
|
||||
return t('nav.alerting.title', 'Alerting');
|
||||
case 'plugin-page-grafana-oncall-app':
|
||||
|
||||
@@ -190,13 +190,13 @@ export function ensureV2Response(
|
||||
export function ensureV1Response(
|
||||
dashboard: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | DashboardWithAccessInfo<DashboardDataDTO>
|
||||
): DashboardDTO {
|
||||
// if dashboard is not on v0 schema or v2 schema, return as is
|
||||
// if dashboard is not on v1 schema or v2 schema, return as is
|
||||
if (!isDashboardResource(dashboard)) {
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
const spec = dashboard.spec;
|
||||
// if dashboard is on v0 schema
|
||||
// if dashboard is on v1 schema
|
||||
if (isDashboardV0Spec(spec)) {
|
||||
return {
|
||||
meta: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { config } from '@grafana/runtime';
|
||||
|
||||
import { getDashboardAPI, setDashboardAPI } from './dashboard_api';
|
||||
import { LegacyDashboardAPI } from './legacy';
|
||||
import { K8sDashboardAPI } from './v0';
|
||||
import { K8sDashboardAPI } from './v1';
|
||||
import { K8sDashboardV2API } from './v2';
|
||||
|
||||
describe('DashboardApi', () => {
|
||||
@@ -29,7 +29,7 @@ describe('DashboardApi', () => {
|
||||
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI);
|
||||
});
|
||||
|
||||
it('should use v0 api when and kubernetesDashboards toggle is enabled', () => {
|
||||
it('should use v1 api when and kubernetesDashboards toggle is enabled', () => {
|
||||
config.featureToggles.kubernetesDashboards = true;
|
||||
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
|
||||
});
|
||||
@@ -55,12 +55,12 @@ describe('DashboardApi', () => {
|
||||
expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI);
|
||||
});
|
||||
|
||||
it('should use v0 api when kubernetesDashboards toggle is enabled', () => {
|
||||
it('should use v1 api when kubernetesDashboards toggle is enabled', () => {
|
||||
config.featureToggles.kubernetesDashboards = true;
|
||||
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
|
||||
});
|
||||
|
||||
it('should use v0 api when kubernetesDashboards and useV2DashboardsAPI toggle is enabled', () => {
|
||||
it('should use v1 api when kubernetesDashboards and useV2DashboardsAPI toggle is enabled', () => {
|
||||
config.featureToggles.useV2DashboardsAPI = true;
|
||||
config.featureToggles.kubernetesDashboards = true;
|
||||
expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI);
|
||||
|
||||
@@ -5,13 +5,12 @@ import { DashboardDTO } from 'app/types';
|
||||
import { LegacyDashboardAPI } from './legacy';
|
||||
import { DashboardAPI, DashboardWithAccessInfo } from './types';
|
||||
import { getDashboardsApiVersion } from './utils';
|
||||
import { K8sDashboardAPI } from './v0';
|
||||
import { K8sDashboardAPI } from './v1';
|
||||
import { K8sDashboardV2API } from './v2';
|
||||
|
||||
type DashboardAPIClients = {
|
||||
legacy: DashboardAPI<DashboardDTO, Dashboard>;
|
||||
v0: DashboardAPI<DashboardDTO, Dashboard>;
|
||||
// v1: DashboardDTO; TODO[schema]: enable v1 when available
|
||||
v1: DashboardAPI<DashboardDTO, Dashboard>;
|
||||
v2: DashboardAPI<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>, DashboardV2Spec>;
|
||||
};
|
||||
|
||||
@@ -40,7 +39,7 @@ export function getDashboardAPI(
|
||||
if (!clients) {
|
||||
clients = {
|
||||
legacy: new LegacyDashboardAPI(),
|
||||
v0: new K8sDashboardAPI(),
|
||||
v1: new K8sDashboardAPI(),
|
||||
v2: new K8sDashboardV2API(isConvertingToV1),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ describe('getDashboardsApiVersion', () => {
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('should return v0 when dashboardScene is disabled and kubernetesDashboards is enabled', () => {
|
||||
it('should return v1 when dashboardScene is disabled and kubernetesDashboards is enabled', () => {
|
||||
config.featureToggles = {
|
||||
dashboardScene: false,
|
||||
kubernetesDashboards: true,
|
||||
};
|
||||
expect(getDashboardsApiVersion()).toBe('v0');
|
||||
expect(getDashboardsApiVersion()).toBe('v1');
|
||||
});
|
||||
|
||||
it('should return legacy when dashboardScene is disabled and kubernetesDashboards is disabled', () => {
|
||||
@@ -31,13 +31,13 @@ describe('getDashboardsApiVersion', () => {
|
||||
expect(getDashboardsApiVersion()).toBe('v2');
|
||||
});
|
||||
|
||||
it('should return v0 when dashboardScene is enabled, useV2DashboardsAPI is disabled, and kubernetesDashboards is enabled', () => {
|
||||
it('should return v1 when dashboardScene is enabled, useV2DashboardsAPI is disabled, and kubernetesDashboards is enabled', () => {
|
||||
config.featureToggles = {
|
||||
dashboardScene: true,
|
||||
useV2DashboardsAPI: false,
|
||||
kubernetesDashboards: true,
|
||||
};
|
||||
expect(getDashboardsApiVersion()).toBe('v0');
|
||||
expect(getDashboardsApiVersion()).toBe('v1');
|
||||
});
|
||||
|
||||
it('should return legacy when dashboardScene is enabled and both useV2DashboardsAPI and kubernetesDashboards are disabled', () => {
|
||||
@@ -71,7 +71,7 @@ describe('getDashboardsApiVersion', () => {
|
||||
kubernetesDashboards: true,
|
||||
};
|
||||
|
||||
expect(getDashboardsApiVersion()).toBe('v0');
|
||||
expect(getDashboardsApiVersion()).toBe('v1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,9 +14,9 @@ export function getDashboardsApiVersion() {
|
||||
|
||||
// if dashboard scene is disabled, use legacy API response for the old architecture
|
||||
if (!config.featureToggles.dashboardScene || forcingOldDashboardArch) {
|
||||
// for old architecture, use v0 API for k8s dashboards
|
||||
// for old architecture, use v1 API for k8s dashboards
|
||||
if (config.featureToggles.kubernetesDashboards) {
|
||||
return 'v0';
|
||||
return 'v1';
|
||||
}
|
||||
|
||||
return 'legacy';
|
||||
@@ -27,20 +27,20 @@ export function getDashboardsApiVersion() {
|
||||
}
|
||||
|
||||
if (config.featureToggles.kubernetesDashboards) {
|
||||
return 'v0';
|
||||
return 'v1';
|
||||
}
|
||||
|
||||
return 'legacy';
|
||||
}
|
||||
|
||||
// This function is used to determine if the dashboard is in v2 format or also v0 format
|
||||
// This function is used to determine if the dashboard is in v2 format or also v1 format
|
||||
export function isDashboardResource(
|
||||
obj?: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | DashboardWithAccessInfo<DashboardDataDTO> | null
|
||||
): obj is DashboardWithAccessInfo<DashboardV2Spec> | DashboardWithAccessInfo<DashboardDataDTO> {
|
||||
if (!obj) {
|
||||
return false;
|
||||
}
|
||||
// is v0 or v2 format?
|
||||
// is v1 or v2 format?
|
||||
const isK8sDashboard = 'kind' in obj && obj.kind === 'DashboardWithAccessInfo';
|
||||
return isK8sDashboard;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function isDashboardV2Spec(obj: Dashboard | DashboardDataDTO | DashboardV
|
||||
}
|
||||
|
||||
export function isDashboardV0Spec(obj: DashboardDataDTO | DashboardV2Spec): obj is DashboardDataDTO {
|
||||
return !isDashboardV2Spec(obj); // not v2 spec means it's v0 spec
|
||||
return !isDashboardV2Spec(obj); // not v2 spec means it's v1 spec
|
||||
}
|
||||
|
||||
export function isDashboardV2Resource(
|
||||
|
||||
+4
-4
@@ -4,11 +4,11 @@ import { AnnoKeyFolder } from 'app/features/apiserver/types';
|
||||
import { DashboardDataDTO } from 'app/types';
|
||||
|
||||
import { DashboardWithAccessInfo } from './types';
|
||||
import { K8sDashboardAPI } from './v0';
|
||||
import { K8sDashboardAPI } from './v1';
|
||||
|
||||
const mockDashboardDto: DashboardWithAccessInfo<DashboardDataDTO> = {
|
||||
kind: 'DashboardWithAccessInfo',
|
||||
apiVersion: 'v0alpha1',
|
||||
apiVersion: 'v1alpha1',
|
||||
|
||||
metadata: {
|
||||
name: 'dash-uid',
|
||||
@@ -28,7 +28,7 @@ const mockDashboardDto: DashboardWithAccessInfo<DashboardDataDTO> = {
|
||||
|
||||
const saveDashboardResponse = {
|
||||
kind: 'Dashboard',
|
||||
apiVersion: 'dashboard.grafana.app/v0alpha1',
|
||||
apiVersion: 'dashboard.grafana.app/v1alpha1',
|
||||
metadata: {
|
||||
name: 'adh59cn',
|
||||
namespace: 'default',
|
||||
@@ -106,7 +106,7 @@ jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({
|
||||
ignoreNextSave: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('v0 dashboard API', () => {
|
||||
describe('v1 dashboard API', () => {
|
||||
it('should provide folder annotations', async () => {
|
||||
jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({
|
||||
id: 1,
|
||||
@@ -26,7 +26,7 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
|
||||
constructor() {
|
||||
this.client = new ScopedResourceClient<DashboardDataDTO>({
|
||||
group: 'dashboard.grafana.app',
|
||||
version: 'v0alpha1',
|
||||
version: 'v1alpha1',
|
||||
resource: 'dashboards',
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AnyAction } from '@reduxjs/toolkit';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
@@ -210,7 +211,7 @@ export function EditDataSourceView({
|
||||
<div key={extension.id}>
|
||||
<Component
|
||||
context={{
|
||||
dataSource: dataSource,
|
||||
dataSource: cloneDeep(dataSource),
|
||||
dataSourceMeta: dataSourceMeta,
|
||||
testingStatus,
|
||||
setJsonData: (jsonData) =>
|
||||
|
||||
@@ -23,14 +23,18 @@ import { getDataSourceSrv, reportInteraction } from '@grafana/runtime';
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
import {
|
||||
AdHocFilterItem,
|
||||
Alert,
|
||||
ErrorBoundaryAlert,
|
||||
LinkButton,
|
||||
PanelContainer,
|
||||
ScrollContainer,
|
||||
Stack,
|
||||
Themeable2,
|
||||
withTheme2,
|
||||
} from '@grafana/ui';
|
||||
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/src/components/Table/types';
|
||||
import { supportedFeatures } from 'app/core/history/richHistoryStorageProvider';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
import { StoreState } from 'app/types';
|
||||
|
||||
@@ -564,6 +568,9 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
if (showCorrelationHelper && correlationEditorHelperData !== undefined) {
|
||||
correlationsBox = <CorrelationHelper exploreId={exploreId} correlations={correlationEditorHelperData} />;
|
||||
}
|
||||
const isDsCompatibleWithDrilldown = ['prometheus', 'loki', 'tempo', 'grafana-pyroscope-datasource'].includes(
|
||||
datasourceInstance?.type || ''
|
||||
);
|
||||
|
||||
return (
|
||||
<ContentOutlineContextProvider refreshDependencies={this.props.queries}>
|
||||
@@ -593,6 +600,27 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
<>
|
||||
<ContentOutlineItem panelId="Queries" title="Queries" icon="arrow" mergeSingleChild={true}>
|
||||
<PanelContainer className={styles.queryContainer}>
|
||||
{isDsCompatibleWithDrilldown && (
|
||||
<Alert
|
||||
severity={'info'}
|
||||
title={t(
|
||||
'explore.drilldownInfo.title',
|
||||
'Explore Metrics, Logs, Traces and Profiles have moved!'
|
||||
)}
|
||||
>
|
||||
<Stack gap={1} alignItems="flex-end" justifyContent={'space-between'}>
|
||||
<span>
|
||||
<Trans i18nKey={'explore.drilldownInfo.description'}>
|
||||
Looking for the Grafana Explore apps? They are now called the Grafana Drilldown apps
|
||||
and can be found under <b>Menu > Drilldown</b>
|
||||
</Trans>
|
||||
</span>
|
||||
<LinkButton variant={'secondary'} href="/drilldown">
|
||||
<Trans i18nKey={'explore.drilldownInfo.action'}>Go to Grafana Drilldown</Trans>
|
||||
</LinkButton>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{correlationsBox}
|
||||
<QueryRows exploreId={exploreId} />
|
||||
<SecondaryActions
|
||||
|
||||
@@ -2,10 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
import { PluginExtensionComponentMeta, PluginExtensionTypes, usePluginContext } from '@grafana/data';
|
||||
import {
|
||||
UsePluginComponentOptions,
|
||||
UsePluginComponentsResult,
|
||||
} from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions';
|
||||
import { UsePluginComponentsOptions, UsePluginComponentsResult } from '@grafana/runtime';
|
||||
|
||||
import { useAddedComponentsRegistry } from './ExtensionRegistriesContext';
|
||||
import * as errors from './errors';
|
||||
@@ -19,7 +16,7 @@ import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './vali
|
||||
export function usePluginComponents<Props extends object = {}>({
|
||||
limitPerPlugin,
|
||||
extensionPointId,
|
||||
}: UsePluginComponentOptions): UsePluginComponentsResult<Props> {
|
||||
}: UsePluginComponentsOptions): UsePluginComponentsResult<Props> {
|
||||
const registry = useAddedComponentsRegistry();
|
||||
const registryState = useObservable(registry.asObservable());
|
||||
const pluginContext = usePluginContext();
|
||||
|
||||
@@ -3,10 +3,7 @@ import { useMemo } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
import { PluginExtensionLink, PluginExtensionTypes, usePluginContext } from '@grafana/data';
|
||||
import {
|
||||
UsePluginLinksOptions,
|
||||
UsePluginLinksResult,
|
||||
} from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions';
|
||||
import { UsePluginLinksOptions, UsePluginLinksResult } from '@grafana/runtime';
|
||||
|
||||
import { useAddedLinksRegistry } from './ExtensionRegistriesContext';
|
||||
import * as errors from './errors';
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function addDataTrailPanelAction(dashboard: DashboardScene, panel:
|
||||
|
||||
if (subMenu.length > 0) {
|
||||
items.push({
|
||||
text: 'Explore metrics',
|
||||
text: 'Metrics drilldown',
|
||||
iconClassName: 'code-branch',
|
||||
subMenu: getUnique(subMenu),
|
||||
});
|
||||
@@ -109,7 +109,7 @@ function createCommonEmbeddedTrailStateProps(item: QueryMetric, dashboard: Dashb
|
||||
|
||||
const commonProps = {
|
||||
scene: embeddedTrail,
|
||||
title: 'Explore metrics',
|
||||
title: 'Metrics drilldown',
|
||||
};
|
||||
|
||||
return commonProps;
|
||||
|
||||
@@ -80,7 +80,7 @@ export class AddToExplorationButton extends SceneObjectBase<AddToExplorationButt
|
||||
return;
|
||||
}
|
||||
const ctx = {
|
||||
origin: 'Explore Metrics',
|
||||
origin: 'Metrics Drilldown',
|
||||
type: 'timeseries',
|
||||
queries,
|
||||
timeRange: { ...timeRange.state.value },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user