merge main

This commit is contained in:
Ryan McKinley
2025-02-20 23:35:18 +03:00
126 changed files with 1666 additions and 1612 deletions
+2 -6
View File
@@ -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
View File
@@ -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
...
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
@@ -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.
+1 -1
View File
@@ -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).
![Explore Profiles home screen](/media/docs/explore-profiles/explore-profiles-homescreen-v0.1.17.png)
![Profiles Drilldown home screen](/media/docs/explore-profiles/explore-profiles-homescreen-v0.1.17.png)
### 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.
![Query editor](/media/docs/pyroscope/query-editor/query-editor.png 'Query editor')
@@ -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.
![show metrics explore overview](/media/metrics-explore/metrics-explore-overview.png)
![show metrics explore overview](/media/metrics-explore/metrics-drilldown-overview.png)
### 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 |
@@ -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(() => {
+14 -14
View File
@@ -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
+22 -22
View File
@@ -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
View File
@@ -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=
+4
View File
@@ -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
View File
@@ -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": {
+1
View File
@@ -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)
**/
+6
View File
@@ -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

+1 -1
View File
@@ -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';
+23 -7
View File
@@ -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;
+4 -1
View File
@@ -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)},
+1
View File
@@ -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)
+41 -26
View File
@@ -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 {
+11 -4
View File
@@ -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
View File
@@ -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) {
+24
View File
@@ -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)
}
+5 -1
View File
@@ -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,
+7 -3
View File
@@ -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
+160 -2
View File
@@ -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))
})
}
+2 -1
View File
@@ -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,
},
}
+26 -34
View File
@@ -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)
}
+3 -2
View File
@@ -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
+6 -5
View File
@@ -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{
+3
View File
@@ -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
}
+8 -4
View File
@@ -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{
+18 -6
View File
@@ -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
}
+4 -1
View File
@@ -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 {
+31 -109
View File
@@ -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)
}
+78 -152
View File
@@ -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)
}
})
}
+4
View File
@@ -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
}
+13 -2
View File
@@ -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
}
}
+9 -4
View File
@@ -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{}
+6 -6
View File
@@ -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
+6 -6
View File
@@ -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=
+1 -1
View File
@@ -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
}
+21 -13
View File
@@ -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")
})
}
+78 -5
View File
@@ -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...),
}
}
+46
View File
@@ -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...)
}
}
+4 -1
View File
@@ -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,
}
+10 -4
View File
@@ -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)
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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=
+8 -10
View File
@@ -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
+14 -467
View File
@@ -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",
+4 -1
View File
@@ -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
}
+36 -12
View File
@@ -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
+8 -3
View File
@@ -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');
});
});
});
+6 -6
View File
@@ -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,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) =>
+28
View File
@@ -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 &gt; 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