Merge branch 'v8.4.x' of github.com:grafana/grafana into v8.4.x

This commit is contained in:
Dominik Prokop
2022-03-31 13:39:07 +02:00
125 changed files with 1978 additions and 882 deletions
+26 -1
View File
@@ -702,6 +702,31 @@ steps:
- yarn install --immutable
image: grafana/build-container:1.5.3
name: initialize
- commands:
- git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"
--depth=1
- cd grafana-enterprise
- git fetch origin "refs/tags/*:refs/tags/*"
- git tag -d $${TEST_TAG} && git push --delete origin $${TEST_TAG} && git tag $${TEST_TAG}
&& git push origin $${TEST_TAG}
- cd -
- git fetch origin "refs/tags/*:refs/tags/*"
- git remote add downstream https://$${GITHUB_TOKEN}@github.com/grafana/$${DOWNSTREAM_REPO}.git
- git tag -d $${TEST_TAG} && git push --delete downstream --quiet $${TEST_TAG} &&
git tag $${TEST_TAG} && git push downstream $${TEST_TAG} --quiet
environment:
DOWNSTREAM_REPO:
from_secret: downstream
GITHUB_TOKEN:
from_secret: github_token
TEST_TAG: v0.0.0-test
failure: ignore
image: grafana/build-container:1.5.3
name: trigger-test-release
when:
paths:
include:
- .drone.yml
- image: grafana/drone-downstream
name: trigger-enterprise-downstream
settings:
@@ -4388,6 +4413,6 @@ kind: secret
name: gcp_upload_artifacts_key
---
kind: signature
hmac: 15995eff6e5351403469b2bedcd93aa4e645d43a94f10eda77a9ee4df17eb53b
hmac: 44e231d2b9a32f86b414ad57bd5d144eb6746baf3b01b037e3a1f4cb94df8a76
...
+21
View File
@@ -1,3 +1,24 @@
<!-- 8.4.4 START -->
# 8.4.4 (2022-03-16)
### Features and enhancements
- **Loki:** Add unpack to autocomplete suggestions (#44623). [#46573](https://github.com/grafana/grafana/pull/46573), [@glintik](https://github.com/glintik)
- **Plugins:** allow using both Function and Class components for app plugins. [#46148](https://github.com/grafana/grafana/pull/46148), [@leventebalogh](https://github.com/leventebalogh)
- **TimeSeries:** Add migration for Graph panel's transform series override. [#46577](https://github.com/grafana/grafana/pull/46577), [@dprokop](https://github.com/dprokop)
- **TimeSeries:** Preserve null/undefined values when performing negative y transform. [#46584](https://github.com/grafana/grafana/pull/46584), [@dprokop](https://github.com/dprokop)
### Bug fixes
- **CloudWatch:** Use default http client from aws-sdk-go. [#46370](https://github.com/grafana/grafana/pull/46370), [@sunker](https://github.com/sunker)
- **Dashboards:** Fixes repeating by row and no refresh. [#46565](https://github.com/grafana/grafana/pull/46565), [@torkelo](https://github.com/torkelo)
- **Gauge:** Fixes blank viz when data link exists and orientation was horizontal. [#46335](https://github.com/grafana/grafana/pull/46335), [@torkelo](https://github.com/torkelo)
- **Search:** sort results correctly when using postgres. [#46466](https://github.com/grafana/grafana/pull/46466), [@xlson](https://github.com/xlson)
- **TagsInput:** fix tags remove button accessibility issues. [#46254](https://github.com/grafana/grafana/pull/46254), [@Elfo404](https://github.com/Elfo404)
- **TextPanel:** Sanitize after markdown has been rendered to html. [#46166](https://github.com/grafana/grafana/pull/46166), [@ashharrison90](https://github.com/ashharrison90)
<!-- 8.4.4 END -->
<!-- 8.4.3 START -->
# 8.4.3 (2022-03-02)
+2 -2
View File
@@ -883,14 +883,14 @@ enabled = true
enabled = false
#################################### Internal Grafana Metrics ############
# Metrics available at HTTP API Url /metrics
# Metrics available at HTTP URL /metrics and /metrics/plugins/:pluginId
[metrics]
enabled = true
interval_seconds = 10
# Disable total stats (stat_totals_*) metrics to be generated
disable_total_stats = false
#If both are set, basic auth will be required for the metrics endpoint.
#If both are set, basic auth will be required for the metrics endpoints.
basic_auth_username =
basic_auth_password =
+2 -2
View File
@@ -865,7 +865,7 @@
;enabled = false
#################################### Internal Grafana Metrics ##########################
# Metrics available at HTTP API Url /metrics
# Metrics available at HTTP URL /metrics and /metrics/plugins/:pluginId
[metrics]
# Disable / Enable internal metrics
;enabled = true
@@ -874,7 +874,7 @@
# Disable total stats (stat_totals_*) metrics to be generated
;disable_total_stats = false
#If both are set, basic auth will be required for the metrics endpoint.
#If both are set, basic auth will be required for the metrics endpoints.
; basic_auth_username =
; basic_auth_password =
@@ -37,3 +37,8 @@ scrape_configs:
- job_name: 'prometheus-random-data'
static_configs:
- targets: ['prometheus-random-data:8080']
# - job_name: 'grafana-test-datasource'
# metrics_path: /metrics/plugins/grafana-test-datasource
# static_configs:
# - targets: ['host.docker.internal:3000']
@@ -202,6 +202,8 @@ Another way is to put a web server like Nginx or Apache in front of Grafana and
### domain
This setting is only used in as a part of the `root_url` setting (see below). Important if you use GitHub or Google OAuth.
### enforce_domain
Redirect to correct domain if the host header does not match the domain. Prevents DNS rebinding attacks. Default is `false`.
@@ -30,7 +30,7 @@ These instructions assume you have already added Prometheus as a data source in
1. Enable Prometheus to scrape metrics from Grafana. In your configuration file (`grafana.ini` or `custom.ini` depending on your operating system) remove the semicolon to enable the following configuration options:
```
# Metrics available at HTTP API Url /metrics
# Metrics available at HTTP URL /metrics and /metrics/plugins/:pluginId
[metrics]
# Disable / Enable internal metrics
enabled = true
@@ -39,7 +39,7 @@ These instructions assume you have already added Prometheus as a data source in
disable_total_stats = false
```
1. (optional) If you want to require authorization to view the metrics endpoint, then uncomment and set the following options:
1. (optional) If you want to require authorization to view the metrics endpoints, then uncomment and set the following options:
```
basic_auth_username =
@@ -92,3 +92,48 @@ These instructions assume you have already added Graphite as a data source in Gr
```
1. Restart Grafana. Grafana now exposes metrics at http://localhost:3000/metrics and sends them to the Graphite location you specified.
## Pull metrics from Grafana backend plugin into Prometheus
Any installed [backend plugin]({{< relref "../../developers/plugins/backend/_index.md" >}}) exposes a metrics endpoint through Grafana that you can configure Prometheus to scrape.
These instructions assume you have already added Prometheus as a data source in Grafana.
1. Enable Prometheus to scrape backend plugin metrics from Grafana. In your configuration file (`grafana.ini` or `custom.ini` depending on your operating system) remove the semicolon to enable the following configuration options:
```
# Metrics available at HTTP URL /metrics and /metrics/plugins/:pluginId
[metrics]
# Disable / Enable internal metrics
enabled = true
# Disable total stats (stat_totals_*) metrics to be generated
disable_total_stats = false
```
1. (optional) If you want to require authorization to view the metrics endpoints, then uncomment and set the following options:
```
basic_auth_username =
basic_auth_password =
```
1. Restart Grafana. Grafana now exposes metrics at `http://localhost:3000/metrics/plugins/<plugin id>`, e.g. http://localhost:3000/metrics/plugins/grafana-github-datasource if you have the [Grafana GitHub datasource](https://grafana.com/grafana/plugins/grafana-github-datasource/) installed.
1. Add the job to your prometheus.yml file.
Example:
```
- job_name: 'grafana_github_datasource'
scrape_interval: 15s
scrape_timeout: 5s
metrics_path: /metrics/plugins/grafana-test-datasource
static_configs:
- targets: ['localhost:3000']
```
1. Restart Prometheus. Your new job should appear on the Targets tab.
1. In Grafana, hover your mouse over the **Configuration** (gear) icon on the left sidebar and then click **Data Sources**.
1. Select the **Prometheus** data source.
1. Import a Golang application metrics dashboard - for example [Go Processes](https://grafana.com/grafana/dashboards/6671).
@@ -149,7 +149,6 @@ Example JSON body:
"version": "1",
"groupKey": "{}:{}",
"truncatedAlerts": 0,
"orgId": 1,
"title": "[FIRING:2] (blue)",
"state": "alerting",
"message": "**Firing**\n\nLabels:\n - alertname = T2\n - team = blue\n - zone = us-1\nAnnotations:\n - description = This is the alert rule checking the second system\n - runbook_url = https://myrunbook.com\n - summary = This is my summary\nSource: https://play.grafana.org/alerting/1afz29v7z/edit\nSilence: https://play.grafana.org/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DT2%2Cteam%3Dblue%2Czone%3Dus-1\n\nLabels:\n - alertname = T1\n - team = blue\n - zone = eu-1\nAnnotations:\nSource: https://play.grafana.org/alerting/d1rdpdv7k/edit\nSilence: https://play.grafana.org/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DT1%2Cteam%3Dblue%2Czone%3Deu-1\n"
@@ -1,5 +1,5 @@
+++
title = " Configure high availability"
title = " High availability"
description = "High Availability"
keywords = ["grafana", "alerting", "tutorials", "ha", "high availability"]
weight = 450
@@ -11,25 +11,7 @@ The Grafana alerting system has two main components: a `Scheduler` and an intern
When it comes to running Grafana alerting in high availability the operational mode of the scheduler is unaffected such that all alerts continue be evaluated in each Grafana instance. Rather the operational change happens in the Alertmanager which **deduplicates** alert notifications across Grafana instances.
```
.─────.
╱ ╲ ┌────────────────┐
( User )──────┐ ┌──────────────────────────────────┐ │ │
`. ,' │ │┌─────────┐ ┌──────────────┐ │ │ ▼
`───' │ ││Scheduler│──────▶Alertmananager│─┼──┘ ┌──────────────────────┐
│ ┌───────────┐ ┌─▶│└─────────┘ ▲──────────────┤ │ │ │
.─────. │ │ Load │ │ │Grafana │ │ │ │ │
╱ ╲ │ │ Balancing │ │ └─────────────────┼──────────────┼─┘ │ Integrations │
( User )──────┼─────▶│ Reverse │──┤ ┌─────────────────┼──────────────┼─┐ │ │
`. ,' │ │ Proxy │ │ │┌─────────┐ ├──────────────▼ │ │ │
`───' │ └───────────┘ │ ││Scheduler│──────▶Alertmananager│─┼──┐ └──────────────────────┘
│ └─▶│└─────────┘ └──────────────┘ │ │ ▲
.─────. │ │Grafana │ │ │
╱ ╲ │ └──────────────────────────────────┘ └────────────────┘
( User )──────┘
`. ,'
`───'
```
{{< figure src="/static/img/docs/alerting/unified/high-availability-ua.png" class="docs-image--no-shadow" max-width= "750px" caption="High availability" >}}
The coordination between Grafana instances happens via [a Gossip protocol](https://en.wikipedia.org/wiki/Gossip_protocol). Alerts are not gossiped between instances. It is expected that each scheduler delivers the same alerts to each Alertmanager.
+1 -1
View File
@@ -19,4 +19,4 @@ This mechanism allows Grafana to remove an existing synchronized user from a tea
<div class="clearfix"></div>
> Team Sync is available in Grafana Enterprise Cloud Pro and Advanced and in Grafana Enterprise. For more information, refer to [Team sync]({{< relref "../enterprise/team-sync.md" >}}) in [Grafana Enterprise]({{< relref "../enterprise" >}}).
> Team Sync is available in both Grafana Enterprise and Grafana Cloud Advanced. For more information, refer to [Team sync]({{< relref "../enterprise/team-sync.md" >}}) in [Grafana Enterprise]({{< relref "../enterprise" >}}).
+18 -51
View File
@@ -4,58 +4,25 @@ aliases = ["/docs/grafana/latest/features/dashboard/dashboards/"]
weight = 80
+++
# Dashboard overview
# About Grafana dashboards
A _dashboard_ is a set of one or more panels organized and arranged into one or more rows. Grafana ships with a variety of Panels. Grafana makes it easy to construct the right queries, and customize the display properties so that you can create the perfect dashboard for your need. Each panel can interact with data from any configured Grafana Data Source (currently Graphite, Prometheus, Elasticsearch, InfluxDB, OpenTSDB, MySQL, PostgreSQL, Microsoft SQL Server and AWS Cloudwatch).
A dashboard is a set of one or more [panels]({{< relref "../panels/_index.md" >}}) organized and arranged into one or more rows. Grafana ships with a variety of panels making it easy to construct the right queries, and customize the visualization so that you can create the perfect dashboard for your need. Each panel can interact with data from any configured Grafana [data source]({{< relref "../datasources/_index.md" >}}).
## Dashboard UI
Dashboard snapshots are static . Queries and expressions cannot be re-executed from snapshots. As a result, if you update any variables in your query or expression, it will not change your dashboard data.
<img src="/static/img/docs/v50/dashboard_annotated.png" class="no-shadow" width="700px">
Before you begin, ensure that you have configured a data source. See also:
1. Zoom out time range
1. Time picker dropdown. Here you can access relative time range options, auto refresh options and set custom absolute time ranges.
1. Manual refresh button. Will cause all panels to refresh (fetch new data).
1. Dashboard panel. Click the panel title to edit panels.
1. Graph legend. You can change series colors, y-axis and series visibility directly from the legend.
## Dashboard header
Click the new Dashboard link on the right side of the Dashboard picker. You now have a blank Dashboard.
<img class="no-shadow" src="/static/img/docs/v50/top_nav_annotated.png" width="580px">
The image above shows you the top header for a Dashboard.
1. Side menubar toggle: This toggles the side menu, allowing you to focus on the data presented in the dashboard. The side menu provides access to features unrelated to a Dashboard such as Users, Organizations, and Data Sources.
1. Dashboard dropdown: This dropdown shows you which Dashboard you are currently viewing, and allows you to easily switch to a new Dashboard. From here you can also create a new Dashboard or folder, import existing Dashboards, and manage Dashboard playlists.
1. Add Panel: Adds a new panel to the current Dashboard.
1. Star Dashboard: Star (or unstar) the current Dashboard. Starred Dashboards will show up on your own Home Dashboard by default, and are a convenient way to mark Dashboards that you're interested in.
1. Share Dashboard: Share the current dashboard by creating a link or create a static Snapshot of it. Make sure the Dashboard is saved before sharing.
1. Save dashboard: The current Dashboard will be saved with the current Dashboard name.
1. Settings: Manage Dashboard settings and features such as Templating and Annotations.
## Manage dashboards
The time period for the dashboard can be controlled by the [Time range controls]({{< relref "time-range-controls.md" >}}) in the upper right of the dashboard.
Dashboards can use [templating]({{< relref "../variables/_index.md" >}}) to make them more dynamic and interactive.
Dashboards can use [annotations]({{< relref "annotations.md" >}}) to display event data across panels. This can help correlate the time series data in the panel with other events.
You can [share dashboards]({{< relref "../sharing/share-dashboard.md" >}}) in a variety of ways.
Dashboards can be tagged, and the dashboard picker provides quick, searchable access to all dashboards in a particular organization.
## Rows
A _row_ is a logical divider within a dashboard. It is used to group panels together.
Rows are always 12 “units” wide. These units are automatically scaled dependent on the horizontal resolution of your browser. You can control the relative width of panels within a row by setting their specific width.
We use a unit abstraction so that Grafana looks great on all screen sizes.
> **Note:** With MaxDataPoint functionality, Grafana can show you the perfect number of data points, regardless of resolution or time range.
Collapse a row by clicking on the row title. If you save a dashboard with a row collapsed, then it saves in that state and does not load those graphs until you expand the row.
Use the [repeating rows]({{< relref "../variables/_index.md#repeating-rows" >}}) functionality to dynamically create or remove entire rows, which can be filled with panels, based on the template variables selected.
- [Working with Grafana dashboard UI]({{< relref "./dashboard-ui/_index.md" >}})
- [Dashboard folders]({{< relref "./dashboard-folders.md" >}})
- [Create dashboard]({{< relref "./dashboard-create" >}})
- [Manage dashboards]({{< relref "./dashboard-manage.md" >}})
- [Annotations]({{< relref "./annotations.md" >}})
- [Playlist]({{< relref "./playlist.md" >}})
- [Search]({{< relref "./search.md" >}})
- [Keyboard shortcuts]({{< relref "./shortcuts.md" >}})
- [Reporting]({{< relref "./reporting.md" >}})
- [Time range controls]({{< relref "./time-range-controls.md" >}})
- [Dashboard version history]({{< relref "./dashboard-history.md" >}})
- [Dashboard export and import]({{< relref "./export-import.md" >}})
- [Dashboard JSON model]({{< relref "./json-model.md" >}})
- [Scripted dashboards]({{< relref "./scripted-dashboards.md" >}})
+3 -3
View File
@@ -2,16 +2,16 @@
title = "Annotations"
keywords = ["grafana", "annotations", "documentation", "guide"]
aliases = ["/docs/grafana/latest/reference/annotations/"]
weight = 2
weight = 9
+++
# Annotations
{{< figure src="/static/img/docs/v46/annotations.png" max-width="800px" >}}
Annotations provide a way to mark points on the graph with rich events. When you hover over an annotation
you can get event description and event tags. The text field can include links to other systems with more detail.
{{< figure src="/static/img/docs/v46/annotations.png" max-width="800px" >}}
## Native annotations
Grafana comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the [HTTP API]({{< relref "../http_api/annotations.md" >}}).
@@ -0,0 +1,11 @@
+++
title = "Create dashboards"
aliases = ["/docs/grafana/latest/features/dashboard/dashboards/"]
weight = 7
+++
## Create dashboard
To create a new dashboard:
Click the new dashboard link on the right side of the time picker. A blank dashboard is created.
@@ -0,0 +1,17 @@
+++
title = "Manage dashboards"
aliases = ["/docs/grafana/latest/features/dashboard/dashboards/"]
weight = 8
+++
## Manage dashboards
When managing dashboads, you can use:
- Select a time period for a dashboard using the [Time range controls]({{< relref "time-range-controls.md" >}}) in the upper right of the dashboard.
- Tag dashboards.
- Use [templating]({{< relref "../variables/_index.md" >}}) to make them more dynamic and interactive.
- Use [annotations]({{< relref "annotations.md" >}}) to display event data across panels. This can help correlate the time series data in the panel with other events.
- Use the dashboard picker for quick, searchable access to all dashboards in a particular organization.
You can also [share dashboards]({{< relref "../sharing/share-dashboard.md" >}}) in a variety of ways.
@@ -0,0 +1,19 @@
+++
title = " Dashboard UI"
aliases = ["/docs/grafana/latest/features/dashboard/dashboards/"]
weight = 2
+++
# Working with Grafana dashboard UI
The dashboard UI has the following sections to allow you to customize the presentation of data.
<img src="/static/img/docs/v50/dashboard_annotated.png" class="no-shadow" width="700px">
- **Zoom out time range** (1)
- **Time picker dropdown** (2). Access relative time range options, auto refresh options and set custom absolute time ranges.
- **Manual refresh option** (3) Fetch new data.
- **Dashboard panel** (4) Click the panel title to edit panels.
- **Graph legend** (5) Change series colors, y-axis and series visibility directly from the legend.
For more details, see [Dashboard header]({{< relref "./dashboard-header.md" >}}) and [Dashboard rows]({{< relref "./dashboard-row.md" >}}).
@@ -0,0 +1,21 @@
+++
title = "Dashboard header"
aliases = ["/docs/grafana/latest/features/dashboard/dashboards/"]
weight = 80
+++
## Dashboard header
The dashboard header has the following sections.
<img class="no-shadow" src="/static/img/docs/v50/top_nav_annotated.png" width="580px">
- **Side menubar toggle** (1): This option toggles the side menu. It provides access to features unrelated to a dashboard such as users, organizations, data sources, and alerting.
- **Dashboard dropdown** (2): Use this option to view the current dashboard name. From here, you can:
- Select another dashboard name to easily switch to that dashboard.
- Create a new dashboard or folder, import existing dashboards, and manage dashboard playlists.
- **Add panel** (3): Use this option to add a new panel to the current dashboard.
- **Star dashboard** (4): Use this option to star (or unstar) the current dashboard. Starred dashboards show up on your own homw dashboard by default. It is a convenient way to mark Dashboards that you're interested in.
- **Share dashboard** (5): Use this option to share the current dashboard by creating a link or create a static snapshot of it. You must save the dashboard before sharing.
- **Save dashboard** (6): Use this option to save the current dashboard using its current name.
- **Settings** (7): Use this option to manage dashboard settings and configure templates and annotations.
@@ -0,0 +1,21 @@
+++
title = "Dashboard rows"
aliases = ["/docs/grafana/latest/features/dashboard/dashboards/"]
weight = 80
+++
# Dshboard rows
A dashboard row is a logical divider within a dashboard. It is used to group panels together.
Grafana uses a base unit abstraction so that dashboards and panels look great on all screen sizes. Dashboard rows are always 12 “units” wide. These units are automatically scaled dependent on the horizontal resolution of your browser. You can control the relative width of panels within a row by setting their specific width.
> **Note:** With MaxDataPoint functionality, Grafana can show you the perfect number of data points, regardless of resolution or time range.
## Create or remove rows
Use the [repeating rows]({{< relref "../../variables/_index.md#repeating-rows" >}}) functionality to dynamically create or remove entire rows, which can be filled with panels, based on the template variables selected.
## Collapse rows
Collapse a row by clicking on the row title. If you save a dashboard with a row collapsed, then it saves in that state and does not load those graphs until you expand the row.
+1 -1
View File
@@ -2,7 +2,7 @@
title = "Dashboard Folders"
keywords = ["grafana", "dashboard", "dashboard folders", "folder", "folders", "documentation", "guide"]
aliases = ["/docs/grafana/latest/reference/dashboard_folders/"]
weight = 3
weight = 6
+++
# Dashboard Folders
+3 -3
View File
@@ -1,11 +1,11 @@
+++
title = "Dashboard Version History"
title = "Dashboard version history"
keywords = ["grafana", "dashboard", "documentation", "version", "history"]
aliases = ["/docs/grafana/latest/reference/dashboard_history/"]
weight = 100
weight = 14
+++
# Dashboard Version History
# Dashboard version history
Whenever you save a version of your dashboard, a copy of that version is saved so that previous versions of your dashboard are never lost. A list of these versions is available by entering the dashboard settings and then selecting "Versions" in the left side menu.
+11 -5
View File
@@ -2,17 +2,23 @@
title = "Playlist"
keywords = ["grafana", "dashboard", "documentation", "playlist"]
aliases = ["/docs/grafana/latest/reference/playlist/"]
weight = 4
weight = 9
+++
# Playlist
A playlist displays a list of dashboards that are in a sequence. Use a playlist to build situational awareness or to present your metrics to your team or visitors. Grafana automatically scales dashboards to any resolution, which makes them perfect for big screens. This topic has the following sections:
- [Access, share, and control a playlist](#access-share-and-control-a-playlist)
- [Create a playlist](#create-a-playlist)
- [Edit or delete a playlist](#edit-or-delete-a-playlist)
- [Share a playlist](#share-a-playlist-in-a-view-mode)
- [Playlist](#playlist)
- [Access, share, and control a playlist](#access-share-and-control-a-playlist)
- [Access playlist](#access-playlist)
- [Start a playlist](#start-a-playlist)
- [Control a playlist](#control-a-playlist)
- [Create a playlist](#create-a-playlist)
- [Edit or delete a playlist](#edit-or-delete-a-playlist)
- [Edit a playlist](#edit-a-playlist)
- [Delete a playlist](#delete-a-playlist)
- [Share a playlist in a view mode](#share-a-playlist-in-a-view-mode)
## Access, share, and control a playlist
+1 -1
View File
@@ -3,7 +3,7 @@ title = "Reporting"
description = ""
keywords = ["grafana", "reporting"]
aliases = ["/docs/grafana/latest/administration/reports"]
weight = 8
weight = 10
+++
# Reporting
+1 -1
View File
@@ -2,7 +2,7 @@
title = "Search"
keywords = ["grafana", "dashboard", "documentation", "search"]
aliases =["/docs/grafana/latest/reference/search/"]
weight = 5
weight = 9
+++
# Dashboard Search
+1 -1
View File
@@ -1,6 +1,6 @@
+++
title = "Keyboard shortcuts"
weight = 7
weight = 10
+++
# Keyboard shortcuts
@@ -2,7 +2,7 @@
title = "Time range controls"
keywords = ["grafana", "dashboard", "documentation", "time range"]
aliases = ["/docs/grafana/latest/reference/timerange/"]
weight = 7
weight = 13
+++
# Time range controls
+1 -1
View File
@@ -8,7 +8,7 @@ weight = 150
# Alertmanager data source
Grafana includes built-in support for Prometheus Alertmanager. It is presently in alpha and not accessible unless [alpha plugins are enabled in Grafana settings](https://grafana.com/docs/grafana/latest/administration/configuration/#enable_alpha). Once you add it as a data source, you can use the [Grafana alerting UI](https://grafana.com/docs/grafana/latest/alerting/) to manage silences, contact points as well as notification policies. A drop-down option in these pages allows you to switch between Grafana and any configured Alertmanager data sources.
Grafana includes built-in support for Prometheus Alertmanager. It is presently in alpha and not accessible unless [alpha plugins are enabled in Grafana settings](https://grafana.com/docs/grafana/latest/administration/configuration/#enable_alpha-1). Once you add it as a data source, you can use the [Grafana alerting UI](https://grafana.com/docs/grafana/latest/alerting/) to manage silences, contact points as well as notification policies. A drop-down option in these pages allows you to switch between Grafana and any configured Alertmanager data sources.
## Alertmanager implementations
+5 -3
View File
@@ -31,10 +31,12 @@ In terms of security:
- Grafana supports signed and encrypted assertions.
- Grafana does not support signed or encrypted requests.
In terms of initiation:
In terms of initiation, Grafana supports:
- Grafana supports SP-initiated requests.
- Grafana does not support IdP-initiated request.
- SP-initiated requests
- IdP-initiated requests
By default, SP-initiated requests are enabled. For instructions on how to enable IdP-initiated logins, see https://grafana.com/docs/grafana/latest/enterprise/saml/#idp-initiated-single-sign-on-sso.
## Set up SAML authentication
+1 -1
View File
@@ -49,7 +49,7 @@ spec:
- 0
containers:
- name: grafana
image: grafana/grafana:7.5.2
image: grafana/grafana:8.4.4
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
+1
View File
@@ -8,6 +8,7 @@ weight = 10000
Here you can find detailed release notes that list everything that is included in every release as well as notices
about deprecations, breaking changes as well as changes that relate to plugin development.
- [Release notes for 8.4.4]({{< relref "release-notes-8-4-4" >}})
- [Release notes for 8.4.3]({{< relref "release-notes-8-4-3" >}})
- [Release notes for 8.4.2]({{< relref "release-notes-8-4-2" >}})
- [Release notes for 8.4.1]({{< relref "release-notes-8-4-1" >}})
@@ -0,0 +1,24 @@
+++
title = "Release notes for Grafana 8.4.4"
hide_menu = true
+++
<!-- Auto generated by update changelog github action -->
# Release notes for Grafana 8.4.4
### Features and enhancements
- **Loki:** Add unpack to autocomplete suggestions (#44623). [#46573](https://github.com/grafana/grafana/pull/46573), [@glintik](https://github.com/glintik)
- **Plugins:** allow using both Function and Class components for app plugins. [#46148](https://github.com/grafana/grafana/pull/46148), [@leventebalogh](https://github.com/leventebalogh)
- **TimeSeries:** Add migration for Graph panel's transform series override. [#46577](https://github.com/grafana/grafana/pull/46577), [@dprokop](https://github.com/dprokop)
- **TimeSeries:** Preserve null/undefined values when performing negative y transform. [#46584](https://github.com/grafana/grafana/pull/46584), [@dprokop](https://github.com/dprokop)
### Bug fixes
- **CloudWatch:** Use default http client from aws-sdk-go. [#46370](https://github.com/grafana/grafana/pull/46370), [@sunker](https://github.com/sunker)
- **Dashboards:** Fixes repeating by row and no refresh. [#46565](https://github.com/grafana/grafana/pull/46565), [@torkelo](https://github.com/torkelo)
- **Gauge:** Fixes blank viz when data link exists and orientation was horizontal. [#46335](https://github.com/grafana/grafana/pull/46335), [@torkelo](https://github.com/torkelo)
- **Search:** sort results correctly when using postgres. [#46466](https://github.com/grafana/grafana/pull/46466), [@xlson](https://github.com/xlson)
- **TagsInput:** fix tags remove button accessibility issues. [#46254](https://github.com/grafana/grafana/pull/46254), [@Elfo404](https://github.com/Elfo404)
- **TextPanel:** Sanitize after markdown has been rendered to html. [#46166](https://github.com/grafana/grafana/pull/46166), [@ashharrison90](https://github.com/ashharrison90)
@@ -26,7 +26,7 @@ backend_04
Regex:
```regex
/.*[01|02]/
/(01|02)$/
```
Result:
+1 -1
View File
@@ -93,7 +93,7 @@ require (
github.com/uber/jaeger-client-go v2.29.1+incompatible
github.com/unknwon/com v1.0.1
github.com/urfave/cli/v2 v2.3.0
github.com/vectordotdev/go-datemath v0.1.1-0.20220110192739-f9ce83ec349f
github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae
github.com/weaveworks/common v0.0.0-20210913144402-035033b78a78
github.com/xorcare/pointer v1.1.0
github.com/yudai/gojsondiff v1.0.0
+2
View File
@@ -2362,6 +2362,8 @@ github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
github.com/vectordotdev/go-datemath v0.1.1-0.20220110192739-f9ce83ec349f h1:2upw/ZfjkCKpc4k6DXg7lMfCSLkfw/8epV5/y2ZUQ8U=
github.com/vectordotdev/go-datemath v0.1.1-0.20220110192739-f9ce83ec349f/go.mod h1:PnwzbSst7KD3vpBzzlntZU5gjVa455Uqa5QPiKSYJzQ=
github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae h1:oyiy3uBj1F4O3AaFh7hUGBrJjAssJhKyAbwxtkslxqo=
github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae/go.mod h1:PnwzbSst7KD3vpBzzlntZU5gjVa455Uqa5QPiKSYJzQ=
github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw=
github.com/vektra/mockery v0.0.0-20181123154057-e78b021dcbb5/go.mod h1:ppEjwdhyy7Y31EnHRDm1JkChoC7LXIJ7Ex0VYLWtZtQ=
github.com/vishvananda/netlink v0.0.0-20171020171820-b2de5d10e38e/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk=
+2 -2
View File
@@ -1,4 +1,4 @@
{
"stable": "8.4.3",
"testing": "8.4.3"
"stable": "8.4.4",
"testing": "8.4.4"
}
+8 -8
View File
@@ -27,18 +27,18 @@
"packages:docsExtract": "rm -rf ./reports/docs && lerna run docsExtract",
"packages:docsToMarkdown": "api-documenter markdown --input-folder ./reports/docs/ --output-folder ./docs/sources/packages_api/ --hugo",
"packages:prepare": "lerna version --no-push --no-git-tag-version --force-publish --exact",
"packages:publish": "lerna publish from-package --contents dist",
"packages:publishCanary": "lerna publish from-package --contents dist --dist-tag canary --yes",
"packages:publishLatest": "lerna publish from-package --contents dist --yes",
"packages:publishNext": "lerna publish from-package --contents dist --dist-tag next --yes",
"packages:publishTest": "lerna publish from-package --contents dist --dist-tag test --yes",
"packages:publishPrevious": "lerna publish from-package --contents dist --dist-tag previous --yes",
"packages:publish": "lerna publish from-package --contents dist --no-verify-access",
"packages:publishCanary": "lerna publish from-package --contents dist --dist-tag canary --yes --no-verify-access",
"packages:publishLatest": "lerna publish from-package --contents dist --yes --no-verify-access",
"packages:publishNext": "lerna publish from-package --contents dist --dist-tag next --yes --no-verify-access",
"packages:publishTest": "lerna publish from-package --contents dist --dist-tag test --yes --no-verify-access",
"packages:publishPrevious": "lerna publish from-package --contents dist --dist-tag previous --yes --no-verify-access",
"packages:publishDev": "lerna publish from-package --contents dist --dist-tag dev --yes --registry http://grafana-npm.local:4873 --force-publish=*",
"packages:typecheck": "lerna run typecheck",
"packages:clean": "lerna run clean",
"precommit": "yarn run lint-staged",
"prettier:check": "prettier --check --list-different=false --loglevel=warn \"**/*.{scss,md,mdx}\"",
"prettier:checkDocs": "prettier --check --list-different=false --loglevel=warn \"docs/**.md\" \"packages/**.{ts,tsx,scss,md,mdx}\"",
"prettier:checkDocs": "prettier --check --list-different=false --loglevel=warn \"docs/**/*.md\" \"packages/**/*.{ts,tsx,scss,md,mdx}\"",
"prettier:write": "prettier --list-different \"**/*.{scss,md,mdx}\" --write",
"start": "yarn themes:generate && yarn dev --watch",
"start:noTsCheck": "yarn start --env noTsCheck=1",
@@ -180,7 +180,7 @@
"eslint-webpack-plugin": "3.1.1",
"expose-loader": "3.1.0",
"file-loader": "6.2.0",
"fork-ts-checker-webpack-plugin": "6.5.0",
"fork-ts-checker-webpack-plugin": "6.4.0",
"fs-extra": "10.0.0",
"glob": "7.2.0",
"html-loader": "3.1.0",
@@ -152,7 +152,7 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform
let bucketOffset = options?.bucketOffset ?? 0;
// if bucket size is auto, try to calc from all numeric fields
if (!bucketSize) {
if (!bucketSize || bucketSize < 0) {
let allValues: number[] = [];
// TODO: include field configs!
@@ -1,4 +1,4 @@
import { VariableModel, ScopedVars } from '@grafana/data';
import { VariableModel, ScopedVars, TimeRange } from '@grafana/data';
/**
* Via the TemplateSrv consumers get access to all the available template variables
@@ -17,6 +17,11 @@ export interface TemplateSrv {
* Replace the values within the target string. See also {@link InterpolateFunction}
*/
replace(target?: string, scopedVars?: ScopedVars, format?: string | Function): string;
/**
* Update the current time range to be used when interpolating __from / __to variables.
*/
updateTimeRange(timeRange: TimeRange): void;
}
let singletonInstance: TemplateSrv;
@@ -18,8 +18,8 @@ apk add --no-cache curl npm yarn build-base openssh git-lfs perl-utils coreutils
# apk add --no-cache xvfb glib nss nspr gdk-pixbuf "gtk+3.0" pango atk cairo dbus-libs libxcomposite libxrender libxi libxtst libxrandr libxscrnsaver alsa-lib at-spi2-atk at-spi2-core cups-libs gcompat libc6-compat
# Install Go
filename="go1.17.linux-amd64.tar.gz"
get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "6bf89fc4f5ad763871cf7eac80a2d594492de7a818303283f1366a7f6a30372d"
filename="go1.17.8.linux-amd64.tar.gz"
get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "980e65a863377e69fd9b67df9d8395fd8e93858e7a24c9f55803421e453f4f99"
untar_file "/tmp/$filename"
# Install golangci-lint
@@ -22,8 +22,8 @@ source "/etc/profile"
npm i -g yarn
# Install Go
filename="go1.17.linux-amd64.tar.gz"
get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "6bf89fc4f5ad763871cf7eac80a2d594492de7a818303283f1366a7f6a30372d"
filename="go1.17.8.linux-amd64.tar.gz"
get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "980e65a863377e69fd9b67df9d8395fd8e93858e7a24c9f55803421e453f4f99"
untar_file "/tmp/$filename"
# Install golangci-lint
@@ -2,8 +2,8 @@
source "./deploy-common.sh"
# Install Go
filename="go1.17.linux-amd64.tar.gz"
get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "6bf89fc4f5ad763871cf7eac80a2d594492de7a818303283f1366a7f6a30372d"
filename="go1.17.8.linux-amd64.tar.gz"
get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "980e65a863377e69fd9b67df9d8395fd8e93858e7a24c9f55803421e453f4f99"
untar_file "/tmp/$filename"
# Install golangci-lint
@@ -1,9 +1,9 @@
import React, { FunctionComponent, useState } from 'react';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import React, { FunctionComponent, useState } from 'react';
import { useStyles2 } from '../../themes/ThemeContext';
import { Icon } from '../Icon/Icon';
import { GrafanaTheme2 } from '@grafana/data';
const getStyles = (theme: GrafanaTheme2) => ({
collapse: css`
@@ -60,7 +60,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
`,
header: css`
label: collapse__header;
padding: ${theme.spacing(1, 2, 0.5, 2)};
padding: ${theme.spacing(1, 2, 1, 2)};
display: flex;
cursor: inherit;
transition: all 0.1s linear;
@@ -68,7 +68,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
`,
headerCollapsed: css`
label: collapse__header--collapsed;
padding: ${theme.spacing(1, 2, 0.5, 2)};
padding: ${theme.spacing(1, 2, 1, 2)};
`,
headerLabel: css`
label: collapse__header-label;
@@ -137,7 +137,7 @@ export const Collapse: FunctionComponent<Props> = ({
return (
<div className={panelClass}>
<div className={headerClass} onClick={onClickToggle}>
{collapsible && <Icon className={style.icon} name={isOpen ? 'angle-up' : 'angle-down'} />}
{collapsible && <Icon className={style.icon} name={isOpen ? 'angle-down' : 'angle-right'} />}
<div className={cx([style.headerLabel])}>{label}</div>
</div>
{isOpen && (
@@ -80,6 +80,7 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = memo(
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
const [linkUrl, setLinkUrl] = useState<Value>(makeValue(value));
const prevLinkUrl = usePrevious<Value>(linkUrl);
const [scrollTop, setScrollTop] = useState(0);
// Workaround for https://github.com/ianstormtaylor/slate/issues/2927
const stateRef = useRef({ showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange });
@@ -87,10 +88,9 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = memo(
// Used to get the height of the suggestion elements in order to scroll to them.
const activeRef = useRef<HTMLDivElement>(null);
const activeIndexPosition = useMemo(
() => getElementPosition(activeRef.current, suggestionsIndex),
[suggestionsIndex]
);
useEffect(() => {
setScrollTop(getElementPosition(activeRef.current, suggestionsIndex));
}, [suggestionsIndex]);
// SelectionReference is used to position the variables suggestion relatively to current DOM selection
const selectionRef = useMemo(() => new SelectionReference(), []);
@@ -183,7 +183,11 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = memo(
{({ ref, style, placement }) => {
return (
<div ref={ref} style={style} data-placement={placement} className={styles.suggestionsWrapper}>
<CustomScrollbar scrollTop={activeIndexPosition} autoHeightMax="300px">
<CustomScrollbar
scrollTop={scrollTop}
autoHeightMax="300px"
setScrollTop={({ scrollTop }) => setScrollTop(scrollTop)}
>
<DataLinkSuggestions
activeRef={activeRef}
suggestions={stateRef.current.suggestions}
@@ -134,6 +134,7 @@ const NarrowScreenForm: React.FC<FormProps> = (props) => {
<fieldset>
<div className={styles.header}>
<button
type={'button'}
className={styles.expandButton}
onClick={() => {
if (!hideQuickRanges) {
@@ -33,16 +33,24 @@ export const WideTimeZoneOption: React.FC<PropsWithChildren<Props>> = (props, re
return null;
}
const timeZoneInfo = getTimeZoneInfo(data.value, timestamp);
return (
<div className={containerStyles} {...innerProps} aria-label="Select option">
<div className={cx(styles.leftColumn, styles.row)}>
<div className={cx(styles.leftColumn, styles.wideRow)}>
<TimeZoneTitle title={children} />
<div className={styles.spacer} />
<TimeZoneDescription info={getTimeZoneInfo(data.value, timestamp)} />
<TimeZoneDescription info={timeZoneInfo} />
</div>
<div className={styles.rightColumn}>
<TimeZoneOffset timeZone={data.value} timestamp={timestamp} className={offsetClassName} />
<TimeZoneOffset
/* Use the timeZoneInfo to pass the correct timeZone name,
as 'Default' has value '' which defaults to browser timezone */
timeZone={timeZoneInfo?.ianaName || data.value}
timestamp={timestamp}
className={offsetClassName}
/>
{isSelected && (
<span>
<Icon name="check" />
@@ -65,6 +73,8 @@ export const CompactTimeZoneOption: React.FC<PropsWithChildren<Props>> = (props,
return null;
}
const timeZoneInfo = getTimeZoneInfo(data.value, timestamp);
return (
<div className={containerStyles} {...innerProps} aria-label="Select option">
<div className={styles.body}>
@@ -82,10 +92,16 @@ export const CompactTimeZoneOption: React.FC<PropsWithChildren<Props>> = (props,
</div>
<div className={styles.row}>
<div className={styles.leftColumn}>
<TimeZoneDescription info={getTimeZoneInfo(data.value, timestamp)} />
<TimeZoneDescription info={timeZoneInfo} />
</div>
<div className={styles.rightColumn}>
<TimeZoneOffset timestamp={timestamp} timeZone={data.value} className={offsetClassName} />
<TimeZoneOffset
timestamp={timestamp}
/* Use the timeZoneInfo to pass the correct timeZone name,
as 'Default' has value '' which defaults to browser timezone */
timeZone={timeZoneInfo?.ianaName || data.value}
className={offsetClassName}
/>
</div>
</div>
</div>
@@ -216,7 +216,7 @@ export class GraphNG extends React.Component<GraphNGProps, GraphNGState> {
const propsChanged = !sameProps(prevProps, this.props, propsToDiff);
if (frames !== prevProps.frames || propsChanged) {
if (frames !== prevProps.frames || propsChanged || timeZone !== prevProps.timeZone) {
let newState = this.prepState(this.props, false);
if (newState) {
@@ -1,7 +1,9 @@
import React, { FC } from 'react';
import { ThresholdsConfig, ThresholdsMode, VizOrientation, getFieldConfigWithMinMax } from '@grafana/data';
import { ThresholdsConfig, ThresholdsMode, VizOrientation, getFieldConfigWithMinMax, LinkModel } from '@grafana/data';
import { BarGauge, BarGaugeDisplayMode } from '../BarGauge/BarGauge';
import { TableCellProps, TableCellDisplayMode } from './types';
import { DataLinksContextMenu, DataLinksContextMenuApi } from '../DataLinks/DataLinksContextMenu';
import { isFunction } from 'lodash';
const defaultScale: ThresholdsConfig = {
mode: ThresholdsMode.Absolute,
@@ -18,7 +20,7 @@ const defaultScale: ThresholdsConfig = {
};
export const BarGaugeCell: FC<TableCellProps> = (props) => {
const { field, innerWidth, tableStyles, cell, cellProps } = props;
const { field, innerWidth, tableStyles, cell, cellProps, row } = props;
let config = getFieldConfigWithMinMax(field, false);
if (!config.thresholds) {
@@ -37,8 +39,20 @@ export const BarGaugeCell: FC<TableCellProps> = (props) => {
barGaugeMode = BarGaugeDisplayMode.Basic;
}
return (
<div {...cellProps} className={tableStyles.cellContainer}>
const getLinks = () => {
if (!isFunction(field.getLinks)) {
return [] as LinkModel[];
}
return field.getLinks({ valueRowIndex: row.index });
};
const hasLinks = !!getLinks().length;
const renderComponent = (menuProps: DataLinksContextMenuApi) => {
const { openMenu, targetClassName } = menuProps;
return (
<BarGauge
width={innerWidth}
height={tableStyles.cellHeightInner}
@@ -48,10 +62,37 @@ export const BarGaugeCell: FC<TableCellProps> = (props) => {
value={displayValue}
orientation={VizOrientation.Horizontal}
theme={tableStyles.theme}
onClick={openMenu}
className={targetClassName}
itemSpacing={1}
lcdCellWidth={8}
displayMode={barGaugeMode}
/>
);
};
return (
<div {...cellProps} className={tableStyles.cellContainer}>
{hasLinks && (
<DataLinksContextMenu links={getLinks} config={config}>
{(api) => renderComponent(api)}
</DataLinksContextMenu>
)}
{!hasLinks && (
<BarGauge
width={innerWidth}
height={tableStyles.cellHeightInner}
field={config}
display={field.display}
text={{ valueSize: 14 }}
value={displayValue}
orientation={VizOrientation.Horizontal}
theme={tableStyles.theme}
itemSpacing={1}
lcdCellWidth={8}
displayMode={barGaugeMode}
/>
)}
</div>
);
};
@@ -6,9 +6,10 @@ import { JSONFormatter } from '../JSONFormatter/JSONFormatter';
import { useStyles2 } from '../../themes';
import { TableCellProps } from './types';
import { GrafanaTheme2 } from '@grafana/data';
import { getCellLinks } from '../../utils';
export function JSONViewCell(props: TableCellProps): JSX.Element {
const { cell, tableStyles, cellProps } = props;
const { cell, tableStyles, cellProps, field, row } = props;
const txt = css`
cursor: pointer;
@@ -28,10 +29,23 @@ export function JSONViewCell(props: TableCellProps): JSX.Element {
const content = <JSONTooltip value={value} />;
const { link, onClick } = getCellLinks(field, row);
return (
<Tooltip placement="auto-start" content={content} theme="info-alt">
<div {...cellProps} className={tableStyles.cellContainer}>
<div className={cx(tableStyles.cellText, txt)}>{displayValue}</div>
{!link && <div className={cx(tableStyles.cellText, txt)}>{value}</div>}
{link && (
<a
href={link.href}
onClick={onClick}
target={link.target}
title={link.title}
className={tableStyles.cellLink}
>
{displayValue}
</a>
)}
</div>
</Tooltip>
);
+1
View File
@@ -464,6 +464,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
m.Use(hs.healthzHandler)
m.Use(hs.apiHealthHandler)
m.Use(hs.metricsEndpoint)
m.Use(hs.pluginMetricsEndpoint)
m.Use(hs.ContextHandler.Middleware)
m.Use(middleware.OrgRedirect(hs.Cfg))
+1 -2
View File
@@ -3,9 +3,8 @@ package api
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
)
func TestHTTPServer_MetricsBasicAuth(t *testing.T) {
+44
View File
@@ -0,0 +1,44 @@
package api
import (
"errors"
"net/http"
"strings"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/web"
)
func (hs *HTTPServer) pluginMetricsEndpoint(ctx *web.Context) {
if !hs.Cfg.MetricsEndpointEnabled {
return
}
if ctx.Req.Method != http.MethodGet || !strings.HasPrefix(ctx.Req.URL.Path, "/metrics/plugins/") {
return
}
if hs.metricsEndpointBasicAuthEnabled() && !BasicAuthenticatedRequest(ctx.Req, hs.Cfg.MetricsEndpointBasicAuthUsername, hs.Cfg.MetricsEndpointBasicAuthPassword) {
ctx.Resp.WriteHeader(http.StatusUnauthorized)
return
}
pathParts := strings.SplitAfter(ctx.Req.URL.Path, "/")
pluginID := pathParts[len(pathParts)-1]
resp, err := hs.pluginClient.CollectMetrics(ctx.Req.Context(), pluginID)
if err != nil {
if errors.Is(err, backendplugin.ErrPluginNotRegistered) {
ctx.Resp.WriteHeader(http.StatusNotFound)
return
}
ctx.Resp.WriteHeader(http.StatusInternalServerError)
return
}
ctx.Resp.Header().Set("Content-Type", "text/plain")
if _, err := ctx.Resp.Write(resp.PrometheusMetrics); err != nil {
hs.log.Error("Failed to write to response", "err", err)
}
}
+160
View File
@@ -0,0 +1,160 @@
package api
import (
"context"
"io/ioutil"
"net/http"
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web/webtest"
"github.com/stretchr/testify/require"
)
func TestPluginMetricsEndpoint(t *testing.T) {
t.Run("Endpoint is enabled, basic auth disabled", func(t *testing.T) {
hs := &HTTPServer{
Cfg: &setting.Cfg{
MetricsEndpointEnabled: true,
MetricsEndpointBasicAuthUsername: "",
MetricsEndpointBasicAuthPassword: "",
},
pluginClient: &fakePluginClientMetrics{
store: map[string][]byte{
"test-plugin": []byte("http_errors=2"),
},
},
}
s := webtest.NewServer(t, routing.NewRouteRegister())
s.Mux.Use(hs.pluginMetricsEndpoint)
t.Run("Endpoint matches and plugin is registered", func(t *testing.T) {
req := s.NewRequest("GET", "/metrics/plugins/test-plugin", nil)
resp, err := s.Send(req)
require.NoError(t, err)
require.NotNil(t, resp)
body, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "http_errors=2", string(body))
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "text/plain", resp.Header.Get("Content-Type"))
})
t.Run("Endpoint matches and plugin is not registered", func(t *testing.T) {
req := s.NewRequest("GET", "/metrics/plugins/plugin-not-registered", nil)
resp, err := s.Send(req)
require.NoError(t, err)
require.NotNil(t, resp)
body, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
require.Empty(t, string(body))
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("Endpoint does not match", func(t *testing.T) {
req := s.NewRequest("GET", "/foo", nil)
resp, err := s.Send(req)
require.NoError(t, err)
require.NotNil(t, resp)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
})
t.Run("Endpoint and basic auth is enabled", func(t *testing.T) {
hs := &HTTPServer{
Cfg: &setting.Cfg{
MetricsEndpointEnabled: true,
MetricsEndpointBasicAuthUsername: "user",
MetricsEndpointBasicAuthPassword: "pwd",
},
pluginClient: &fakePluginClientMetrics{
store: map[string][]byte{
"test-plugin": []byte("http_errors=2"),
},
},
}
s := webtest.NewServer(t, routing.NewRouteRegister())
s.Mux.Use(hs.pluginMetricsEndpoint)
t.Run("When plugin is registered, wrong basic auth credentials should return 401", func(t *testing.T) {
req := s.NewRequest("GET", "/metrics/plugins/test-plugin", nil)
req.SetBasicAuth("user2", "pwd2")
resp, err := s.Send(req)
require.NoError(t, err)
require.NotNil(t, resp)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("When plugin is registered, correct basic auth credentials should return 200", func(t *testing.T) {
req := s.NewRequest("GET", "/metrics/plugins/test-plugin", nil)
req.SetBasicAuth("user", "pwd")
resp, err := s.Send(req)
require.NoError(t, err)
require.NotNil(t, resp)
body, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "http_errors=2", string(body))
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "text/plain", resp.Header.Get("Content-Type"))
})
})
t.Run("Endpoint is disabled", func(t *testing.T) {
hs := &HTTPServer{
Cfg: &setting.Cfg{
MetricsEndpointEnabled: false,
},
pluginClient: &fakePluginClientMetrics{
store: map[string][]byte{
"test-plugin": []byte("http_errors=2"),
},
},
}
s := webtest.NewServer(t, routing.NewRouteRegister())
s.Mux.Use(hs.pluginMetricsEndpoint)
t.Run("When plugin is registered, should return 404", func(t *testing.T) {
req := s.NewRequest("GET", "/metrics/plugins/test-plugin", nil)
resp, err := s.Send(req)
require.NoError(t, err)
require.NotNil(t, resp)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
})
}
type fakePluginClientMetrics struct {
plugins.Client
store map[string][]byte
}
func (c *fakePluginClientMetrics) CollectMetrics(_ context.Context, pluginID string) (*backend.CollectMetricsResult, error) {
metrics, exists := c.store[pluginID]
if !exists {
return nil, backendplugin.ErrPluginNotRegistered
}
return &backend.CollectMetricsResult{
PrometheusMetrics: metrics,
}, nil
}
+1 -1
View File
@@ -89,7 +89,7 @@ func (w *FileLogWriter) Init() error {
if err := w.StartLogger(); err != nil {
return err
}
w.logger = log.NewLogfmtLogger(log.NewSyncWriter(w))
w.logger = w.Format(log.NewSyncWriter(w))
return nil
}
+19 -12
View File
@@ -35,15 +35,20 @@ type PluginManager struct {
pluginInstaller plugins.Installer
pluginLoader plugins.Loader
pluginsMu sync.RWMutex
pluginPaths map[plugins.Class][]string
pluginSources []PluginSource
log log.Logger
}
type PluginSource struct {
Class plugins.Class
Paths []string
}
func ProvideService(grafanaCfg *setting.Cfg, pluginLoader plugins.Loader) (*PluginManager, error) {
pm := New(plugins.FromGrafanaCfg(grafanaCfg), map[plugins.Class][]string{
plugins.Core: corePluginPaths(grafanaCfg),
plugins.Bundled: {grafanaCfg.BundledPluginsPath},
plugins.External: append([]string{grafanaCfg.PluginsPath}, pluginSettingPaths(grafanaCfg)...),
pm := New(plugins.FromGrafanaCfg(grafanaCfg), []PluginSource{
{Class: plugins.Core, Paths: corePluginPaths(grafanaCfg)},
{Class: plugins.Bundled, Paths: []string{grafanaCfg.BundledPluginsPath}},
{Class: plugins.External, Paths: append([]string{grafanaCfg.PluginsPath}, pluginSettingPaths(grafanaCfg)...)},
}, pluginLoader)
if err := pm.Init(); err != nil {
return nil, err
@@ -51,11 +56,11 @@ func ProvideService(grafanaCfg *setting.Cfg, pluginLoader plugins.Loader) (*Plug
return pm, nil
}
func New(cfg *plugins.Cfg, pluginPaths map[plugins.Class][]string, pluginLoader plugins.Loader) *PluginManager {
func New(cfg *plugins.Cfg, pluginSources []PluginSource, pluginLoader plugins.Loader) *PluginManager {
return &PluginManager{
cfg: cfg,
pluginLoader: pluginLoader,
pluginPaths: pluginPaths,
pluginSources: pluginSources,
store: make(map[string]*plugins.Plugin),
log: log.New("plugin.manager"),
pluginInstaller: installer.New(false, cfg.BuildVersion, newInstallerLogger("plugin.installer", true)),
@@ -63,8 +68,8 @@ func New(cfg *plugins.Cfg, pluginPaths map[plugins.Class][]string, pluginLoader
}
func (m *PluginManager) Init() error {
for class, paths := range m.pluginPaths {
err := m.loadPlugins(context.Background(), class, paths...)
for _, ps := range m.pluginSources {
err := m.loadPlugins(context.Background(), ps.Class, ps.Paths...)
if err != nil {
return err
}
@@ -357,13 +362,15 @@ func (m *PluginManager) start(ctx context.Context, p *plugins.Plugin) error {
return backendplugin.ErrPluginNotRegistered
}
if p.IsCorePlugin() {
return nil
}
if err := startPluginAndRestartKilledProcesses(ctx, p); err != nil {
return err
}
if !p.IsCorePlugin() {
p.Logger().Debug("Successfully started backend plugin process")
}
p.Logger().Debug("Successfully started backend plugin process")
return nil
}
+31 -2
View File
@@ -20,6 +20,21 @@ const (
testPluginID = "test-plugin"
)
func TestPluginManager_Init(t *testing.T) {
t.Run("Plugin sources are loaded in order", func(t *testing.T) {
loader := &fakeLoader{}
pm := New(&plugins.Cfg{}, []PluginSource{
{Class: plugins.Bundled, Paths: []string{"path1"}},
{Class: plugins.Core, Paths: []string{"path2"}},
{Class: plugins.External, Paths: []string{"path3"}},
}, loader)
err := pm.Init()
require.NoError(t, err)
require.Equal(t, []string{"path1", "path2", "path3"}, loader.loadedPaths)
})
}
func TestPluginManager_loadPlugins(t *testing.T) {
t.Run("Managed backend plugin", func(t *testing.T) {
p, pc := createPlugin(testPluginID, "", plugins.External, true, true)
@@ -227,7 +242,7 @@ func TestPluginManager_Installer(t *testing.T) {
err := pm.loadPlugins(context.Background(), plugins.Core, "test/path")
require.NoError(t, err)
assert.Equal(t, 1, pc.startCount)
assert.Equal(t, 0, pc.startCount)
assert.Equal(t, 0, pc.stopCount)
assert.False(t, pc.exited)
assert.False(t, pc.decommissioned)
@@ -418,6 +433,20 @@ func TestPluginManager_lifecycle_managed(t *testing.T) {
})
})
})
newScenario(t, true, func(t *testing.T, ctx *managerScenarioCtx) {
t.Run("Backend core plugin is registered but not started", func(t *testing.T) {
ctx.plugin.Class = plugins.Core
err := ctx.manager.registerAndStart(context.Background(), ctx.plugin)
require.NoError(t, err)
require.NotNil(t, ctx.plugin)
require.Equal(t, testPluginID, ctx.plugin.ID)
require.Equal(t, 0, ctx.pluginClient.startCount)
testPlugin, exists := ctx.manager.Plugin(context.Background(), testPluginID)
assert.True(t, exists)
require.NotNil(t, testPlugin)
})
})
}
func TestPluginManager_lifecycle_unmanaged(t *testing.T) {
@@ -526,7 +555,7 @@ func newScenario(t *testing.T, managed bool, fn func(t *testing.T, ctx *managerS
manager: manager,
}
ctx.plugin, ctx.pluginClient = createPlugin(testPluginID, "", plugins.Core, managed, true)
ctx.plugin, ctx.pluginClient = createPlugin(testPluginID, "", plugins.External, managed, true)
fn(t, ctx)
}
+10 -4
View File
@@ -14,8 +14,9 @@ import (
)
const (
ttl = 30 * time.Second
cleanInterval = 2 * time.Minute
ttl = 30 * time.Second
cleanInterval = 2 * time.Minute
maxPrefixParts = 2
)
func GetResourceScope(resource string, resourceID string) string {
@@ -144,10 +145,15 @@ func (s *ScopeResolver) GetResolveAttributeScopeMutator(orgID int64) ScopeMutato
}
}
// scopePrefix returns the prefix associated to a given scope
// we assume prefixes are all in the form <resource>:<attribute>:<value>
// ex: "datasources:name:test" returns "datasources:name:"
func scopePrefix(scope string) string {
parts := strings.Split(scope, ":")
n := len(parts) - 1
parts[n] = ""
// We assume prefixes don't have more than maxPrefixParts parts
if len(parts) > maxPrefixParts {
parts = append(parts[:maxPrefixParts], "")
}
return strings.Join(parts, ":")
}
+28
View File
@@ -66,6 +66,10 @@ func TestScopeResolver_ResolveAttribute(t *testing.T) {
return Scope("datasources", "id", "1"), nil
} else if initialScope == "datasources:name:testds2" {
return Scope("datasources", "id", "2"), nil
} else if initialScope == "datasources:name:test:ds4" {
return Scope("datasources", "id", "4"), nil
} else if initialScope == "datasources:name:testds5*" {
return Scope("datasources", "id", "5"), nil
} else {
return "", models.ErrDataSourceNotFound
}
@@ -118,6 +122,20 @@ func TestScopeResolver_ResolveAttribute(t *testing.T) {
),
wantCalls: 2,
},
{
name: "should resolve name with colon",
orgID: 1,
evaluator: EvalPermission("datasources:read", Scope("datasources", "name", "test:ds4")),
wantEvaluator: EvalPermission("datasources:read", Scope("datasources", "id", "4")),
wantCalls: 1,
},
{
name: "should resolve names with '*'",
orgID: 1,
evaluator: EvalPermission("datasources:read", Scope("datasources", "name", "testds5*")),
wantEvaluator: EvalPermission("datasources:read", Scope("datasources", "id", "5")),
wantCalls: 1,
},
}
for _, tt := range tests {
resolver := NewScopeResolver()
@@ -167,6 +185,16 @@ func Test_scopePrefix(t *testing.T) {
scope: "datasources:name:testds",
want: "datasources:name:",
},
{
name: "datasources with colons in name",
scope: "datasources:name:test:a::ds",
want: "datasources:name:",
},
{
name: "prefix",
scope: "datasources:name:",
want: "datasources:name:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -86,7 +86,7 @@
"links": [
{
"name": "Learn more",
"url": "https://github.com/grafana/azure-monitor-datasource"
"url": "https://grafana.com/docs/grafana/latest/datasources/azuremonitor/"
},
{
"name": "Apache License",
@@ -112,8 +112,8 @@
"path": "public/app/plugins/datasource/grafana-azure-monitor-datasource/img/azure_monitor_cpu.png"
}
],
"version": "0.3.0",
"updated": "2018-12-06"
"version": "1.0.0",
"updated": ""
},
"dependencies": {
"grafanaDependency": "",
@@ -1553,4 +1553,4 @@
"signatureType": "",
"signatureOrg": ""
}
]
]
@@ -126,7 +126,9 @@ func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInf
if dimSB.String() != "" {
params.Add("$filter", dimSB.String())
params.Add("top", azJSONModel.Top)
if azJSONModel.Top != "" {
params.Add("top", azJSONModel.Top)
}
}
target = params.Encode()
@@ -114,6 +114,17 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
expectedInterval: "PT1M",
azureMonitorQueryTarget: "%24filter=blob+eq+%27%2A%27+and+tier+eq+%27%2A%27&aggregation=Average&api-version=2018-01-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute-virtualMachines&timespan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z&top=30",
},
{
name: "has a dimension filter without specifying a top",
azureMonitorVariedProperties: map[string]interface{}{
"timeGrain": "PT1M",
"dimension": "blob",
"dimensionFilter": "*",
},
queryInterval: duration,
expectedInterval: "PT1M",
azureMonitorQueryTarget: "%24filter=blob+eq+%27%2A%27&aggregation=Average&api-version=2018-01-01&interval=PT1M&metricnames=Percentage+CPU&metricnamespace=Microsoft.Compute-virtualMachines&timespan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z",
},
}
commonAzureModelProps := map[string]interface{}{
+9 -9
View File
@@ -148,15 +148,15 @@ var metricsMap = map[string][]string{
"MasterReachableFromNode",
"MasterSysMemoryUtilization",
"Nodes",
"OpenSearchDashboardsConcurrentConnections (previously KibanaConcurrentConnections)",
"OpenSearchDashboardsHealthyNode (previously KibanaHealthyNode)",
"OpenSearchDashboardsHealthyNodes (previously KibanaHealthyNodes)",
"OpenSearchDashboardsHeapTotal (previously KibanaHeapTotal)",
"OpenSearchDashboardsHeapUsed (previously KibanaHeapUsed)",
"OpenSearchDashboardsHeapUtilization (previously KibanaHeapUtilization)",
"OpenSearchDashboardsOS1MinuteLoad (previously KibanaOS1MinuteLoad)",
"OpenSearchDashboardsRequestTotal (previously KibanaRequestTotal)",
"OpenSearchDashboardsResponseTimesMaxInMillis (previously KibanaResponseTimesMaxInMillis)",
"OpenSearchDashboardsConcurrentConnections",
"OpenSearchDashboardsHealthyNode",
"OpenSearchDashboardsHealthyNodes",
"OpenSearchDashboardsHeapTotal",
"OpenSearchDashboardsHeapUsed",
"OpenSearchDashboardsHeapUtilization",
"OpenSearchDashboardsOS1MinuteLoad",
"OpenSearchDashboardsRequestTotal",
"OpenSearchDashboardsResponseTimesMaxInMillis",
"OpenSearchRequests",
"PPLFailedRequestCountByCusErr",
"PPLFailedRequestCountBySysErr",
+1 -5
View File
@@ -158,11 +158,7 @@ func (t parsableTime) datemathOptions() []func(*datemath.Options) {
options = append(options, datemath.WithLocation(t.location))
}
if t.weekstart != nil {
weekstart := *t.weekstart
if weekstart > t.now.Weekday() {
weekstart = weekstart - 7
}
options = append(options, datemath.WithStartOfWeek(weekstart))
options = append(options, datemath.WithStartOfWeek(*t.weekstart))
}
if t.fiscalStartMonth != nil {
loc := time.UTC
+2
View File
@@ -169,6 +169,8 @@ func generateConnectionString(dsInfo sqleng.DataSourceInfo) (string, error) {
if certificate != "" {
connStr += fmt.Sprintf("certificate=%s;", certificate)
}
} else if encrypt == "disable" {
connStr += fmt.Sprintf("encrypt=%s;", dsInfo.JsonData.Encrypt)
}
return connStr, nil
}
@@ -32,7 +32,7 @@ const getStyles = (theme: GrafanaTheme2) => {
border-radius: ${theme.shape.borderRadius(5)};
background-color: ${theme.colors.success.main};
padding: ${theme.spacing(0.25, 0.75)};
color: ${theme.colors.text.maxContrast};
color: white; // use the same color for both themes
font-weight: ${theme.typography.fontWeightMedium};
font-size: ${theme.typography.pxToRem(10)};
`,
+173 -39
View File
@@ -1,4 +1,4 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import React, { HTMLAttributes } from 'react';
import { css, cx } from '@emotion/css';
import { Icon, LinkButton, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
@@ -6,48 +6,30 @@ import { GrafanaTheme2 } from '@grafana/data';
type ComponentSize = 'sm' | 'md';
export interface Props extends HTMLAttributes<HTMLOrSVGElement> {
text?: string;
featureName: string;
size?: ComponentSize;
children?: ReactNode;
secondaryAction?: {
url: string;
text: string;
};
}
export const UpgradeBox = ({ text, className, children, secondaryAction, size = 'md', ...htmlProps }: Props) => {
export const UpgradeBox = ({ featureName, className, children, size = 'md', ...htmlProps }: Props) => {
const styles = useStyles2((theme) => getUpgradeBoxStyles(theme, size));
return (
<div className={cx(styles.box, className)} {...htmlProps}>
<Icon name={'rocket'} className={styles.icon} />
<div>
<h4>You’ve found a Pro feature!</h4>
{text && <p className={styles.text}>{text}</p>}
{children}
<div className={styles.inner}>
<p className={styles.text}>
You’ve discovered a Pro feature! Get the Grafana Pro plan to access {featureName}.
</p>
<LinkButton
variant="primary"
variant="secondary"
size={size}
className={styles.button}
href="https://grafana.com/profile/org/subscription"
target="__blank"
rel="noopener noreferrer"
>
Upgrade to Pro
Upgrade
</LinkButton>
{secondaryAction && (
<LinkButton
variant="link"
size={size}
className={cx(styles.button, styles.buttonSecondary)}
href={secondaryAction.url}
target="__blank"
rel="noopener noreferrer"
>
{secondaryAction.text}
</LinkButton>
)}
</div>
</div>
);
@@ -60,26 +42,35 @@ const getUpgradeBoxStyles = (theme: GrafanaTheme2, size: ComponentSize) => {
return {
box: css`
display: flex;
align-items: center;
position: relative;
border-radius: ${borderRadius};
background: ${theme.colors.primary.transparent};
border: 1px solid ${theme.colors.primary.shade};
background: ${theme.colors.success.transparent};
padding: ${theme.spacing(2)};
color: ${theme.colors.primary.text};
color: ${theme.colors.success.text};
font-size: ${theme.typography[fontBase].fontSize};
text-align: left;
line-height: 16px;
margin: ${theme.spacing(0, 'auto', 3, 'auto')};
max-width: ${theme.breakpoints.values.xxl}px;
width: 100%;
`,
inner: css`
display: flex;
align-items: center;
width: 100%;
justify-content: space-between;
`,
text: css`
margin-bottom: 0;
padding: ${theme.spacing(2, 0)};
line-height: 1.5;
margin: 0;
`,
button: css`
margin-top: ${theme.spacing(2)};
background-color: ${theme.colors.success.main};
font-weight: ${theme.typography.fontWeightLight};
color: white;
&:first-of-type {
margin-right: ${theme.spacing(1)};
&:hover {
background-color: ${theme.colors.success.main};
}
&:focus-visible {
@@ -88,11 +79,154 @@ const getUpgradeBoxStyles = (theme: GrafanaTheme2, size: ComponentSize) => {
outline: 2px solid ${theme.colors.primary.main};
}
`,
buttonSecondary: css`
color: ${theme.colors.text.secondary};
`,
icon: css`
margin: ${theme.spacing(0.5, 1, 0.5, 0.5)};
`,
};
};
export interface UpgradeContentProps {
image: string;
featureUrl?: string;
featureName: string;
description?: string;
listItems: string[];
caption?: string;
}
export const UpgradeContent = ({
listItems,
image,
featureUrl,
featureName,
description,
caption,
}: UpgradeContentProps) => {
const styles = useStyles2(getUpgradeContentStyles);
return (
<div className={styles.container}>
<div className={styles.content}>
<h3 className={styles.title}>Get started with {featureName}</h3>
{description && <h6 className={styles.description}>{description}</h6>}
<ul className={styles.list}>
{listItems.map((item, index) => (
<li key={index}>
<Icon name={'check'} size={'xl'} className={styles.icon} /> {item}
</li>
))}
</ul>
{featureUrl && (
<LinkButton fill={'text'} href={featureUrl} className={styles.link} target="_blank" rel="noreferrer noopener">
Learn more
</LinkButton>
)}
</div>
<div className={styles.media}>
<img src={getImgUrl(image)} alt={'Feature screenshot'} />
{caption && <p className={styles.caption}>{caption}</p>}
</div>
</div>
);
};
const getUpgradeContentStyles = (theme: GrafanaTheme2) => {
return {
container: css`
display: flex;
justify-content: space-between;
`,
content: css`
width: 45%;
margin-right: ${theme.spacing(4)};
`,
media: css`
width: 55%;
img {
width: 100%;
}
`,
description: css`
color: ${theme.colors.text.primary};
font-weight: ${theme.typography.fontWeightLight};
`,
list: css`
list-style: none;
margin: ${theme.spacing(4, 0, 2, 0)};
li {
display: flex;
align-items: flex-start;
color: ${theme.colors.text.primary};
padding: ${theme.spacing(1, 0)};
}
`,
icon: css`
color: ${theme.colors.success.main};
margin-right: ${theme.spacing(1)};
`,
link: css`
margin-left: ${theme.spacing(2)};
`,
title: css`
color: ${theme.colors.text.maxContrast};
`,
caption: css`
font-weight: ${theme.typography.fontWeightLight};
margin: ${theme.spacing(1, 0, 0)};
`,
};
};
export const UpgradeContentVertical = ({
featureName,
description,
featureUrl,
image,
}: Omit<UpgradeContentProps, 'listItems' | 'caption'>) => {
const styles = useStyles2(getContentVerticalStyles);
return (
<div className={styles.container}>
<h3 className={styles.title}>Get started with {featureName}</h3>
{description && <h6 className={styles.description}>{description}</h6>}
<LinkButton fill={'text'} href={featureUrl} target="_blank" rel="noreferrer noopener">
Learn more
</LinkButton>
<div className={styles.media}>
<img src={getImgUrl(image)} alt={'Feature screenshot'} />
</div>
</div>
);
};
const getContentVerticalStyles = (theme: GrafanaTheme2) => {
return {
container: css`
overflow: auto;
height: 100%;
`,
title: css`
color: ${theme.colors.text.maxContrast};
`,
description: css`
color: ${theme.colors.text.primary};
font-weight: ${theme.typography.fontWeightLight};
`,
media: css`
width: 100%;
margin-top: ${theme.spacing(2)};
img {
width: 100%;
}
`,
};
};
const getImgUrl = (urlOrId: string) => {
if (urlOrId.startsWith('http')) {
return urlOrId;
}
return '/public/img/enterprise/highlights/' + urlOrId;
};
@@ -1,26 +0,0 @@
import React, { useEffect } from 'react';
import { Modal } from '@grafana/ui';
import { reportExperimentView } from '@grafana/runtime';
import { UpgradeBox } from './UpgradeBox';
export interface Props {
title: string;
text: string;
isOpen?: boolean;
onDismiss?: () => void;
experimentId?: string;
}
export const UpgradeModal = ({ title, text, isOpen, onDismiss, experimentId }: Props) => {
useEffect(() => {
if (experimentId) {
reportExperimentView(experimentId, 'test', '');
}
}, [experimentId]);
return (
<Modal title={title} isOpen={isOpen} onDismiss={onDismiss}>
<UpgradeBox text={text} />
</Modal>
);
};
+17 -14
View File
@@ -326,24 +326,27 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi
const seriesWithFields = logSeries.filter((series) => series.fields.length);
if (seriesWithFields.length) {
allSeries = seriesWithFields.map((series) => {
seriesWithFields.forEach((series) => {
const fieldCache = new FieldCache(series);
const stringField = fieldCache.getFirstFieldOfType(FieldType.string);
const timeField = fieldCache.getFirstFieldOfType(FieldType.time);
if (stringField?.labels) {
allLabels.push(stringField.labels);
if (stringField !== undefined && timeField !== undefined) {
if (stringField?.labels) {
allLabels.push(stringField.labels);
}
allSeries.push({
series,
timeField,
timeNanosecondField: fieldCache.hasFieldWithNameAndType('tsNs', FieldType.time)
? fieldCache.getFieldByName('tsNs')
: undefined,
stringField,
logLevelField: fieldCache.getFieldByName('level'),
idField: getIdField(fieldCache),
});
}
return {
series,
timeField: fieldCache.getFirstFieldOfType(FieldType.time),
timeNanosecondField: fieldCache.hasFieldWithNameAndType('tsNs', FieldType.time)
? fieldCache.getFieldByName('tsNs')
: undefined,
stringField,
logLevelField: fieldCache.getFieldByName('level'),
idField: getIdField(fieldCache),
} as LogFields;
});
}
@@ -23,8 +23,8 @@ export function interceptLinkClicks(e: MouseEvent) {
// Make sure external links are handled correctly
// That is they where seen as being absolute from app root
if (href[0] !== '/') {
// if still contains protocol it's an absolute link to another domain or web application
if (href.indexOf('://') > 0) {
// if still contains protocol or is a mailto link, it's an absolute link to another domain or web application
if (href.indexOf('://') > 0 || href.indexOf('mailto:') === 0) {
window.location.href = href;
return;
} else {
@@ -61,8 +61,10 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
const buildShareUrl = () => {
if (isCloudRulesSource(rulesSource)) {
const { appUrl, appSubUrl } = config;
const baseUrl = appSubUrl !== '' ? `${appUrl}${appSubUrl}/` : config.appUrl;
const ruleUrl = `${encodeURIComponent(rulesSource.name)}/${encodeURIComponent(rule.name)}`;
return `${config.appUrl}${config.appSubUrl}/alerting/${ruleUrl}/find`;
return `${baseUrl}alerting/${ruleUrl}/find`;
}
return window.location.href.split('?')[0];
@@ -46,7 +46,7 @@ export const AnnotationPicker = ({ annotation, events, onEnabledChanged }: Annot
<InlineSwitch value={annotation.enable} onChange={() => onEnabledChanged(annotation)} disabled={loading} />
</InlineField>
<div className={styles.indicator}>
<LoadingIndicator loading={true} onCancel={onCancel} />
<LoadingIndicator loading={loading} onCancel={onCancel} />
</div>
</InlineFieldRow>
</div>
@@ -17,6 +17,13 @@ const dataSources = {
prom: mockDataSource({
name: 'prom',
type: 'prometheus',
isDefault: true,
}),
notDefault: mockDataSource({
name: 'prom-not-default',
uid: 'prom-not-default-uid',
type: 'prometheus',
isDefault: false,
}),
[MIXED_DATASOURCE_NAME]: mockDataSource({
name: MIXED_DATASOURCE_NAME,
@@ -1840,6 +1847,33 @@ describe('DashboardModel', () => {
});
});
describe('when fixing query and panel data source refs out of sync due to default data source change', () => {
let model: DashboardModel;
beforeEach(() => {
model = new DashboardModel({
templating: {
list: [],
},
panels: [
{
id: 2,
datasource: null,
targets: [
{
datasource: 'prom-not-default',
},
],
},
],
});
});
it('should not update panel datasource to that of query level ds', () => {
expect(model.panels[0].datasource?.uid).toEqual('prom-not-default-uid');
});
});
describe('when migrating time series axis visibility', () => {
test('preserves x axis visibility', () => {
const model = new DashboardModel({
@@ -67,6 +67,40 @@ export class DashboardMigrator {
this.dashboard = dashboardModel;
}
/**
* When changing default datasource which is stored as null Grafana get's into a mixed state where queries have
* data source uid & type set that is different from the now new default
*/
syncQueryDataSources() {
const dataSourceSrv = getDataSourceSrv();
// This only happens in some unit tests that does not set a DataSourceSrv
if (!dataSourceSrv) {
return;
}
const defaultDS = getDataSourceSrv().getInstanceSettings(null);
// if default ds is mixed then skip this
if (!defaultDS || defaultDS.meta.mixed) {
return;
}
for (const panel of this.dashboard.panels) {
// only interested in panels that use default (null) data source
if (panel.datasource) {
continue;
}
for (const target of panel.targets) {
// If query level data source is different from panel
if (target.datasource && target.datasource.uid !== defaultDS?.uid) {
// set panel level data source to data source on the query as this is more likely the correct one
// But impossible to say, and this changes the behavior of of what default means ahead of the big change to default
panel.datasource = target.datasource;
}
}
}
}
updateSchema(old: any) {
let i, j, k, n;
const oldVersion = this.dashboard.schemaVersion;
@@ -1067,6 +1067,7 @@ export class DashboardModel {
private updateSchema(old: any) {
const migrator = new DashboardMigrator(this);
migrator.updateSchema(old);
migrator.syncQueryDataSources();
}
resetOriginalTime() {
@@ -106,9 +106,9 @@ export function importDashboard(data: any, dashboardTitle: string): ThunkResult<
};
}
export function removeDashboard(uri: string): ThunkResult<void> {
export function removeDashboard(uid: string): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().delete(`/api/dashboards/${uri}`);
await getBackendSrv().delete(`/api/dashboards/uid/${uid}`);
dispatch(loadPluginDashboards());
};
}
@@ -71,7 +71,7 @@ export class DataSourceDashboards extends PureComponent<Props> {
};
onRemove = (dashboard: PluginDashboard) => {
this.props.removeDashboard(dashboard.importedUri);
this.props.removeDashboard(dashboard.uid);
};
render() {
@@ -195,6 +195,25 @@ describe('createSpanLinkFactory', () => {
)}`
);
});
it('handles empty queries', () => {
const createLink = setupSpanLinkFactory({
tags: [],
});
expect(createLink).toBeDefined();
const linkDef = createLink!(
createTraceSpan({
process: {
serviceName: 'service',
tags: [
{ key: 'service.name', value: 'serviceName' },
{ key: 'k8s.pod.name', value: 'podName' },
],
},
})
);
expect(linkDef).toBeUndefined();
});
});
});
@@ -75,12 +75,17 @@ function legacyCreateSpanLinkFactory(splitOpenFn: SplitOpen, traceToLogsOptions?
return undefined;
}
return function SpanLink(span: TraceSpan): SpanLinkDef {
return function SpanLink(span: TraceSpan): SpanLinkDef | undefined {
// This is reusing existing code from derived fields which may not be ideal match so some data is a bit faked at
// the moment. Issue is that the trace itself isn't clearly mapped to dataFrame (right now it's just a json blob
// inside a single field) so the dataLinks as config of that dataFrame abstraction breaks down a bit and we do
// it manually here instead of leaving it for the data source to supply the config.
const expr = getLokiQueryFromSpan(span, traceToLogsOptions);
if (!expr) {
return undefined;
}
const dataLink: DataLink<LokiQuery> = {
title: dataSourceSettings.name,
url: '',
@@ -88,7 +93,7 @@ function legacyCreateSpanLinkFactory(splitOpenFn: SplitOpen, traceToLogsOptions?
datasourceUid: dataSourceSettings.uid,
datasourceName: dataSourceSettings.name,
query: {
expr: getLokiQueryFromSpan(span, traceToLogsOptions),
expr,
refId: '',
},
},
@@ -122,7 +127,7 @@ function legacyCreateSpanLinkFactory(splitOpenFn: SplitOpen, traceToLogsOptions?
*/
const defaultKeys = ['cluster', 'hostname', 'namespace', 'pod'];
function getLokiQueryFromSpan(span: TraceSpan, options: TraceToLogsOptions): string {
function getLokiQueryFromSpan(span: TraceSpan, options: TraceToLogsOptions): string | undefined {
const { tags: keys, filterByTraceID, filterBySpanID, mapTagNamesEnabled, mappedTags } = options;
// In order, try to use mapped tags -> tags -> default tags
@@ -143,6 +148,11 @@ function getLokiQueryFromSpan(span: TraceSpan, options: TraceToLogsOptions): str
return acc;
}, [] as string[]);
// If no tags found, return undefined to prevent an invalid Loki query
if (!tags.length) {
return undefined;
}
let query = `{${tags.join(', ')}}`;
if (filterByTraceID && span.traceID) {
@@ -8,6 +8,14 @@ import { of } from 'rxjs';
jest.mock('../../dashboard/services/TimeSrv', () => ({
getTimeSrv: jest.fn().mockReturnValue({
init: jest.fn(),
timeRange: jest.fn().mockReturnValue({}),
}),
}));
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object),
getTemplateSrv: () => ({
updateTimeRange: jest.fn(),
}),
}));
@@ -80,7 +88,7 @@ function setup(state?: any) {
return Object.values(datasources).map((d) => ({ name: d.name }));
},
getInstanceSettings(name: string) {
return { name, getRef: () => ({ uid: name }) };
return { name, getRef: () => ({ uid: name }), meta: { name } };
},
get(name?: string) {
return Promise.resolve(
@@ -90,6 +98,7 @@ function setup(state?: any) {
testDatasource: jest.fn(),
init: jest.fn(),
name: 'default',
meta: {},
}
);
},
@@ -35,6 +35,21 @@ import { configureStore } from '../../../store/configureStore';
import { setTimeSrv } from '../../dashboard/services/TimeSrv';
import Mock = jest.Mock;
jest.mock('app/features/dashboard/services/TimeSrv', () => ({
...jest.requireActual('app/features/dashboard/services/TimeSrv'),
getTimeSrv: () => ({
init: jest.fn(),
timeRange: jest.fn().mockReturnValue({}),
}),
}));
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object),
getTemplateSrv: () => ({
updateTimeRange: jest.fn(),
}),
}));
const t = toUtc();
const testRange = {
from: t,
+15 -7
View File
@@ -8,6 +8,7 @@ import {
TimeRange,
} from '@grafana/data';
import { RefreshPicker } from '@grafana/ui';
import { getTemplateSrv } from '@grafana/runtime';
import { getTimeRange, refreshIntervalToSortOrder, stopQueryState } from 'app/core/utils/explore';
import { ExploreItemState, ThunkResult } from 'app/types';
@@ -95,15 +96,22 @@ export const updateTime = (config: {
const range = getTimeRange(timeZone, rawRange, fiscalYearStartMonth);
const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
getTimeSrv().init(
new DashboardModel({
time: range.raw,
refresh: false,
timeZone,
})
const timeModel: DashboardModel = Object.assign(
new DashboardModel({ time: range.raw, refresh: false, timepicker: {} }),
{
getTimezone: () => timeZone,
timeRangeUpdated: (rawTimeRange: RawTimeRange) => {
dispatch(updateTimeRange({ exploreId: exploreId, rawRange: rawTimeRange }));
},
}
);
// We need to re-initialize TimeSrv because it might have been triggered by the other Explore pane (when split)
getTimeSrv().init(timeModel);
// After re-initializing TimeSrv we need to update the time range in Template service for interpolation
// of __from and __to variables
getTemplateSrv().updateTimeRange(getTimeSrv().timeRange());
dispatch(changeRangeAction({ exploreId, range, absoluteRange }));
};
};
@@ -22,6 +22,7 @@ describe('getFieldLinksForExplore', () => {
getVariables() {
return [];
},
updateTimeRange(timeRange: TimeRange) {},
});
});
@@ -2,7 +2,6 @@ import React, { FC } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data';
import { ConfirmModal, stylesFactory, useTheme } from '@grafana/ui';
import { locationService } from '@grafana/runtime';
import { DashboardSection, OnDeleteItems } from '../types';
import { getCheckedUids } from '../utils';
import { deleteFoldersAndDashboards } from 'app/features/manage-dashboards/state/actions';
@@ -40,8 +39,6 @@ export const ConfirmDeleteModal: FC<Props> = ({ results, onDeleteItems, isOpen,
const deleteItems = () => {
deleteFoldersAndDashboards(folders, dashboards).then(() => {
onDismiss();
// Redirect to /dashboard in case folder was deleted from f/:folder.uid
locationService.push('/dashboards');
onDeleteItems(folders, dashboards);
});
};
+221 -32
View File
@@ -1,4 +1,31 @@
import { DashboardSearchItemType, DashboardSection } from './types';
import { DashboardSearchItemType, DashboardSection, DashboardSectionItem } from './types';
function makeSection(sectionPartial: Partial<DashboardSection>): DashboardSection {
return {
title: 'Default title',
id: Number.MAX_SAFE_INTEGER - 1,
score: -99,
expanded: true,
type: DashboardSearchItemType.DashFolder,
items: [],
url: '/default-url',
...sectionPartial,
};
}
const makeSectionItem = (itemPartial: Partial<DashboardSectionItem>): DashboardSectionItem => {
return {
id: Number.MAX_SAFE_INTEGER - 2,
uid: 'default-uid',
title: 'Default dashboard title',
type: DashboardSearchItemType.DashDB,
isStarred: false,
tags: [],
uri: 'db/default-slug',
url: '/d/default-uid/default-slug',
...itemPartial,
};
};
export const generalFolder: DashboardSection = {
id: 0,
@@ -52,61 +79,62 @@ export const searchResults: DashboardSection[] = [
];
// Search results with more info
export const sections = [
{
export const sections: DashboardSection[] = [
makeSection({
title: 'Starred',
score: -2,
expanded: true,
items: [
{
makeSectionItem({
id: 1,
uid: 'lBdLINUWk',
title: 'Prom dash',
type: DashboardSearchItemType.DashDB,
},
}),
],
},
{
}),
makeSection({
title: 'Recent',
icon: 'clock-o',
score: -1,
removable: true,
expanded: false,
items: [
{
makeSectionItem({
id: 4072,
uid: 'OzAIf_rWz',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
},
{
}),
makeSectionItem({
id: 46,
uid: '8DY63kQZk',
title: 'Stocks',
type: DashboardSearchItemType.DashDB,
isStarred: false,
},
{
}),
makeSectionItem({
id: 20,
uid: '7MeksYbmk',
title: 'Alerting with TestData',
type: DashboardSearchItemType.DashDB,
isStarred: false,
folderId: 2,
},
{
}),
makeSectionItem({
id: 4073,
uid: 'j9SHflrWk',
title: 'New dashboard Copy 4',
type: DashboardSearchItemType.DashDB,
isStarred: false,
folderId: 2,
},
}),
],
},
{
}),
makeSection({
id: 2,
uid: 'JB_zdOUWk',
title: 'gdev dashboards',
@@ -115,8 +143,9 @@ export const sections = [
icon: 'folder',
score: 2,
items: [],
},
{
}),
makeSection({
id: 2568,
uid: 'search-test-data',
title: 'Search test data folder',
@@ -125,8 +154,9 @@ export const sections = [
url: '/dashboards/f/search-test-data/search-test-data-folder',
icon: 'folder',
score: 3,
},
{
}),
makeSection({
id: 4074,
uid: 'iN5TFj9Zk',
title: 'Test',
@@ -135,38 +165,197 @@ export const sections = [
url: '/dashboards/f/iN5TFj9Zk/test',
icon: 'folder',
score: 4,
},
{
}),
makeSection({
id: 0,
title: 'General',
icon: 'folder-open',
score: 5,
expanded: true,
items: [
{
makeSectionItem({
id: 4069,
uid: 'LCFWfl9Zz',
title: 'New dashboard Copy',
uri: 'db/new-dashboard-copy',
url: '/d/LCFWfl9Zz/new-dashboard-copy',
slug: '',
type: DashboardSearchItemType.DashDB,
isStarred: false,
},
{
}),
makeSectionItem({
id: 4072,
uid: 'OzAIf_rWz',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
},
{
}),
makeSectionItem({
id: 1,
uid: 'lBdLINUWk',
title: 'Prom dash',
type: DashboardSearchItemType.DashDB,
isStarred: true,
},
}),
],
},
}),
];
export const checkedGeneralFolder: DashboardSection[] = [
makeSection({
id: 4074,
uid: 'other-folder-dash',
title: 'Test',
expanded: false,
type: DashboardSearchItemType.DashFolder,
items: [
makeSectionItem({
id: 4072,
uid: 'other-folder-dash-abc',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
}),
makeSectionItem({
id: 46,
uid: 'other-folder-dash-def',
title: 'Stocks',
type: DashboardSearchItemType.DashDB,
isStarred: false,
}),
],
url: '/dashboards/f/iN5TFj9Zk/test',
icon: 'folder',
score: 4,
}),
makeSection({
id: 0,
title: 'General',
uid: 'other-folder-abc',
score: 5,
expanded: true,
checked: true,
type: DashboardSearchItemType.DashFolder,
items: [
makeSectionItem({
id: 4069,
uid: 'general-abc',
title: 'New dashboard Copy',
uri: 'db/new-dashboard-copy',
url: '/d/LCFWfl9Zz/new-dashboard-copy',
type: DashboardSearchItemType.DashDB,
isStarred: false,
checked: true,
}),
makeSectionItem({
id: 4072,
uid: 'general-def',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
checked: true,
}),
makeSectionItem({
id: 1,
uid: 'general-ghi',
title: 'Prom dash',
type: DashboardSearchItemType.DashDB,
isStarred: true,
checked: true,
}),
],
}),
];
export const checkedOtherFolder: DashboardSection[] = [
makeSection({
id: 4074,
uid: 'other-folder-abc',
title: 'Test',
expanded: false,
checked: true,
type: DashboardSearchItemType.DashFolder,
items: [
makeSectionItem({
id: 4072,
uid: 'other-folder-dash-abc',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
checked: true,
}),
makeSectionItem({
id: 46,
uid: 'other-folder-dash-def',
title: 'Stocks',
type: DashboardSearchItemType.DashDB,
isStarred: false,
checked: true,
}),
],
url: '/dashboards/f/iN5TFj9Zk/test',
icon: 'folder',
score: 4,
}),
makeSection({
id: 0,
title: 'General',
icon: 'folder-open',
score: 5,
expanded: true,
type: DashboardSearchItemType.DashFolder,
items: [
makeSectionItem({
id: 4069,
uid: 'general-abc',
title: 'New dashboard Copy',
uri: 'db/new-dashboard-copy',
url: '/d/LCFWfl9Zz/new-dashboard-copy',
type: DashboardSearchItemType.DashDB,
isStarred: false,
}),
makeSectionItem({
id: 4072,
uid: 'general-def',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
}),
makeSectionItem({
id: 1,
uid: 'general-ghi',
title: 'Prom dash',
type: DashboardSearchItemType.DashDB,
isStarred: true,
}),
],
}),
];
export const folderViewAllChecked: DashboardSection[] = [
makeSection({
checked: true,
selected: true,
title: '',
items: [
makeSectionItem({
id: 4072,
uid: 'other-folder-dash-abc',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
checked: true,
}),
makeSectionItem({
id: 46,
uid: 'other-folder-dash-def',
title: 'Stocks',
type: DashboardSearchItemType.DashDB,
isStarred: false,
checked: true,
}),
],
}),
];
+22 -8
View File
@@ -7,7 +7,7 @@ import {
mergeReducers,
parseRouteParams,
} from './utils';
import { sections, searchResults } from './testData';
import { sections, searchResults, checkedGeneralFolder, checkedOtherFolder, folderViewAllChecked } from './testData';
import { SearchQueryParams } from './types';
describe('Search utils', () => {
@@ -131,21 +131,35 @@ describe('Search utils', () => {
});
describe('getCheckedUids', () => {
it('should return object with empty arrays if no checked items are available', () => {
expect(getCheckedUids(sections as any[])).toEqual({ folders: [], dashboards: [] });
it('should not return any UIDs if no items are checked', () => {
expect(getCheckedUids(sections)).toEqual({ folders: [], dashboards: [] });
});
it('should return uids for all checked items', () => {
expect(getCheckedUids(searchResults as any[])).toEqual({
folders: ['JB_zdOUWk'],
dashboards: ['lBdLINUWk', '8DY63kQZk'],
it('should return only dashboard UIDs if the General folder is checked', () => {
expect(getCheckedUids(checkedGeneralFolder)).toEqual({
folders: [],
dashboards: ['general-abc', 'general-def', 'general-ghi'],
});
});
it('should return only dashboard UIDs if all items are checked when viewing a folder', () => {
expect(getCheckedUids(folderViewAllChecked)).toEqual({
folders: [],
dashboards: ['other-folder-dash-abc', 'other-folder-dash-def'],
});
});
it('should return folder + dashboard UIDs when folder is checked in the root view', () => {
expect(getCheckedUids(checkedOtherFolder)).toEqual({
folders: ['other-folder-abc'],
dashboards: ['other-folder-dash-abc', 'other-folder-dash-def'],
});
});
});
describe('getCheckedDashboardsUids', () => {
it('should get uids of all checked dashboards', () => {
expect(getCheckedDashboardsUids(searchResults as any[])).toEqual(['lBdLINUWk', '8DY63kQZk']);
expect(getCheckedDashboardsUids(searchResults)).toEqual(['lBdLINUWk', '8DY63kQZk']);
});
});
+1 -1
View File
@@ -165,7 +165,7 @@ export const getCheckedUids = (sections: DashboardSection[]): UidsToDelete => {
}
return sections.reduce((result, section) => {
if (section?.id !== 0 && section.checked) {
if (section?.id !== 0 && section.checked && section.uid) {
return { ...result, folders: [...result.folders, section.uid] } as UidsToDelete;
} else {
return { ...result, dashboards: getCheckedDashboardsUids(sections) } as UidsToDelete;
+2 -1
View File
@@ -4,7 +4,7 @@ import { Props, TeamPages } from './TeamPages';
import { OrgRole, Team, TeamMember } from '../../types';
import { getMockTeam } from './__mocks__/teamMocks';
import { User } from 'app/core/services/context_srv';
import { NavModel } from '@grafana/data';
import { NavModel, createTheme } from '@grafana/data';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
jest.mock('@grafana/runtime/src/config', () => ({
@@ -35,6 +35,7 @@ const setup = (propOverrides?: object) => {
team: {} as Team,
members: [] as TeamMember[],
editorsCanAdmin: false,
theme: createTheme(),
signedInUser: {
id: 1,
isGrafanaAdmin: false,
+20 -9
View File
@@ -1,6 +1,7 @@
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { includes } from 'lodash';
import { Themeable2, withTheme2 } from '@grafana/ui';
import config from 'app/core/config';
import Page from 'app/core/components/Page/Page';
import TeamMembers from './TeamMembers';
@@ -16,14 +17,14 @@ import { contextSrv } from 'app/core/services/context_srv';
import { NavModel } from '@grafana/data';
import { featureEnabled, reportExperimentView } from '@grafana/runtime';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { UpgradeBox } from 'app/core/components/Upgrade/UpgradeBox';
import { UpgradeBox, UpgradeContent } from 'app/core/components/Upgrade/UpgradeBox';
interface TeamPageRouteParams {
id: string;
page: string | null;
}
export interface OwnProps extends GrafanaRouteComponentProps<TeamPageRouteParams> {}
export interface OwnProps extends GrafanaRouteComponentProps<TeamPageRouteParams>, Themeable2 {}
interface State {
isSyncEnabled: boolean;
@@ -140,7 +141,7 @@ export class TeamPages extends PureComponent<Props, State> {
renderPage(isSignedInUserTeamAdmin: boolean): React.ReactNode {
const { isSyncEnabled } = this.state;
const { members, team } = this.props;
const { members, team, theme } = this.props;
const currentPage = this.getCurrentPage();
const canReadTeam = contextSrv.hasAccessInMetadata(
@@ -175,11 +176,21 @@ export class TeamPages extends PureComponent<Props, State> {
}
} else if (config.featureToggles.featureHighlights) {
return (
<UpgradeBox
text={
"Team Sync immediately updates each user's Grafana teams and permissions based on their LDAP or Oauth group membership, instead of updating when users sign in."
}
/>
<>
<UpgradeBox featureName={'team sync'} />
<UpgradeContent
listItems={[
'Stop managing user access in two places - assign users to groups in SAML, LDAP or Oauth, and manage access at a Team level in Grafana',
'Update users’ permissions immediately when you add or remove them from an LDAP group, with no need for them to sign out and back in',
]}
image={`team-sync-${theme.isLight ? 'light' : 'dark'}.png`}
featureName={'team sync'}
featureUrl={'https://grafana.com/docs/grafana/latest/enterprise/team-sync'}
description={
'Team Sync makes it easier for you to manage users’ access in Grafana, by immediately updating each user’s Grafana teams and permissions based on their single sign-on group membership, instead of when users sign in.'
}
/>
</>
);
}
}
@@ -201,4 +212,4 @@ export class TeamPages extends PureComponent<Props, State> {
}
}
export default connector(TeamPages);
export default connector(withTheme2(TeamPages));
@@ -1,4 +1,4 @@
import { ScopedVars, VariableModel } from '@grafana/data';
import { ScopedVars, TimeRange, VariableModel } from '@grafana/data';
import { variableRegex } from '../variables/utils';
import { TemplateSrv } from '@grafana/runtime';
@@ -45,4 +45,6 @@ export class TemplateSrvMock implements TemplateSrv {
}
return match.slice(1).find((match) => match !== undefined);
}
updateTimeRange(timeRange: TimeRange) {}
}
@@ -41,7 +41,6 @@ export default function createMockDatasource(overrides?: DeepPartial<Datasource>
getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue([]),
getResourcesForResourceGroup: jest.fn().mockResolvedValue([]),
getResourceURIFromWorkspace: jest.fn().mockReturnValue(''),
transformVariablesToRow: jest.fn().mockReturnValue({}),
},
...overrides,
};
@@ -10,7 +10,6 @@ export default function createMockResourcePickerData(overrides?: DeepPartial<Res
getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue([]),
getResourcesForResourceGroup: jest.fn().mockResolvedValue([]),
getResourceURIFromWorkspace: jest.fn().mockReturnValue(''),
transformVariablesToRow: jest.fn().mockReturnValue({}),
...overrides,
};
@@ -1,115 +1,9 @@
import { ResourceRowGroup, ResourceRowType } from '../components/ResourcePicker/types';
export const createMockResourcePickerRows = (): ResourceRowGroup => [
{
id: '/subscriptions/abc-123',
name: 'Primary Subscription',
type: ResourceRowType.Subscription,
typeLabel: 'Subscription',
children: [
{
id: '/subscriptions/abc-123/resourceGroups/prod',
name: 'Production',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
},
{
id: '/subscriptions/abc-123/resourceGroups/pre-prod',
name: 'Pre-production',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
},
],
},
{
id: '/subscriptions/def-456',
name: 'Dev Subscription',
type: ResourceRowType.Subscription,
typeLabel: 'Subscription',
children: [
{
id: '/subscriptions/def-456/resourceGroups/dev',
name: 'Development',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [
{
id: '/subscription/def-456/resourceGroups/dev/providers/Microsoft.Compute/virtualMachines/web-server',
name: 'web-server',
typeLabel: 'Microsoft.Compute/virtualMachines',
type: ResourceRowType.Resource,
location: 'northeurope',
},
{
id: '/subscription/def-456/resourceGroups/dev/providers/Microsoft.Compute/disks/web-server_DataDisk',
name: 'web-server_DataDisk',
typeLabel: 'Microsoft.Compute/disks',
type: ResourceRowType.Resource,
location: 'northeurope',
},
{
id: '/subscription/def-456/resourceGroups/dev/providers/Microsoft.Compute/virtualMachines/db-server',
name: 'db-server',
typeLabel: 'Microsoft.Compute/virtualMachines',
type: ResourceRowType.Resource,
location: 'northeurope',
},
{
id: '/subscription/def-456/resourceGroups/dev/providers/Microsoft.Compute/disks/db-server_DataDisk',
name: 'db-server_DataDisk',
typeLabel: 'Microsoft.Compute/disks',
type: ResourceRowType.Resource,
location: 'northeurope',
},
],
},
{
id: '/subscriptions/def-456/resourceGroups/test',
name: 'Test',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
},
{
id: '/subscriptions/def-456/resourceGroups/qa',
name: 'QA',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
},
],
},
{
id: '$$grafana-templateVariables$$',
name: 'Template variables',
type: ResourceRowType.VariableGroup,
typeLabel: 'Variables',
children: [
{
id: '$machine',
name: '$machine',
type: ResourceRowType.Variable,
typeLabel: 'Variable',
},
{
id: '$workspace',
name: '$workspace',
type: ResourceRowType.Variable,
typeLabel: 'Variable',
},
],
},
];
export const createMockSubscriptions = (): ResourceRowGroup => [
{
id: 'def-123',
uri: '/subscriptions/def-123',
name: 'Primary Subscription',
type: ResourceRowType.Subscription,
typeLabel: 'Subscription',
@@ -117,6 +11,7 @@ export const createMockSubscriptions = (): ResourceRowGroup => [
},
{
id: 'def-456',
uri: '/subscriptions/def-456',
name: 'Dev Subscription',
type: ResourceRowType.Subscription,
typeLabel: 'Subscription',
@@ -124,6 +19,7 @@ export const createMockSubscriptions = (): ResourceRowGroup => [
},
{
id: 'def-789',
uri: '/subscriptions/def-789',
name: 'Test Subscription',
type: ResourceRowType.Subscription,
typeLabel: 'Subscription',
@@ -133,35 +29,40 @@ export const createMockSubscriptions = (): ResourceRowGroup => [
export const createMockResourceGroupsBySubscription = (): ResourceRowGroup => [
{
id: '/subscriptions/def-456/resourceGroups/dev-1',
id: 'dev-1',
uri: '/subscriptions/def-456/resourceGroups/dev-1',
name: 'Development',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
},
{
id: '/subscriptions/def-456/resourceGroups/dev-2',
id: 'dev-2',
uri: '/subscriptions/def-456/resourceGroups/dev-2',
name: 'Development',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
},
{
id: '/subscriptions/def-456/resourceGroups/dev-3',
id: 'dev-3',
uri: '/subscriptions/def-456/resourceGroups/dev-3',
name: 'A Great Resource Group',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
},
{
id: 'dev-4',
uri: '/subscriptions/def-456/resourceGroups/dev-4',
name: 'Development',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
},
{
id: '/subscriptions/def-456/resourceGroups/dev-4',
name: 'Development',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
},
{
id: '/subscriptions/def-456/resourceGroups/dev-5',
id: 'dev-5',
uri: '/subscriptions/def-456/resourceGroups/dev-5',
name: 'Development',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
@@ -171,14 +72,16 @@ export const createMockResourceGroupsBySubscription = (): ResourceRowGroup => [
export const mockResourcesByResourceGroup = (): ResourceRowGroup => [
{
id: 'Microsoft.Compute/virtualMachines/web-server',
id: 'web-server',
uri: '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/web-server',
name: 'web-server',
typeLabel: 'Microsoft.Compute/virtualMachines',
type: ResourceRowType.Resource,
location: 'northeurope',
},
{
id: 'Microsoft.Compute/disks/web-server_DataDisk',
id: 'web-server_DataDisk',
uri: '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/disks/web-server_DataDisk',
name: 'web-server_DataDisk',
typeLabel: 'Microsoft.Compute/disks',
type: ResourceRowType.Resource,
@@ -186,7 +89,8 @@ export const mockResourcesByResourceGroup = (): ResourceRowGroup => [
},
{
id: 'Microsoft.Compute/virtualMachines/db-server',
id: 'db-server',
uri: '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/db-server',
name: 'db-server',
typeLabel: 'Microsoft.Compute/virtualMachines',
type: ResourceRowType.Resource,
@@ -194,7 +98,8 @@ export const mockResourcesByResourceGroup = (): ResourceRowGroup => [
},
{
id: 'Microsoft.Compute/disks/db-server_DataDisk',
id: 'db-server_DataDisk',
uri: '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/disks/db-server_DataDisk',
name: 'db-server_DataDisk',
typeLabel: 'Microsoft.Compute/disks',
type: ResourceRowType.Resource,
@@ -317,6 +317,31 @@ export const locationMetadata = [
displayName: 'Brazil Southeast',
supportsLogs: true,
},
{
location: 'chinaeast',
displayName: 'China Eest',
supportsLogs: true,
},
{
location: 'chinaeast2',
displayName: 'China Eest 2',
supportsLogs: true,
},
{
location: 'chinanorth',
displayName: 'China North',
supportsLogs: true,
},
{
location: 'chinanorth2',
displayName: 'China North 2',
supportsLogs: true,
},
{
location: 'chinanorth3',
displayName: 'China North 3',
supportsLogs: true,
},
];
export const logsSupportedLocationsKusto = locationMetadata
@@ -1,108 +0,0 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { createMockResourcePickerRows } from '../../__mocks__/resourcePickerRows';
import NestedResourceTable from './NestedResourceTable';
import { findRow } from './utils';
describe('AzureMonitor NestedResourceTable', () => {
const noop: any = () => {};
const getElementById = document.getElementById;
beforeEach(() => {
document.getElementById = jest.fn().mockReturnValue({
scrollIntoView: jest.fn(),
});
});
afterEach(() => {
document.getElementById = getElementById;
});
it('renders subscriptions', () => {
const rows = createMockResourcePickerRows();
render(<NestedResourceTable rows={rows} selectedRows={[]} requestNestedRows={noop} onRowSelectedChange={noop} />);
expect(screen.getByText('Primary Subscription')).toBeInTheDocument();
expect(screen.getByText('Dev Subscription')).toBeInTheDocument();
});
it('opens to the selected resource', () => {
const rows = createMockResourcePickerRows();
const selected = findRow(
rows,
'/subscription/def-456/resourceGroups/dev/providers/Microsoft.Compute/disks/web-server_DataDisk'
);
if (!selected) {
throw new Error("couldn't find row, test data stale");
}
render(
<NestedResourceTable rows={rows} selectedRows={[selected]} requestNestedRows={noop} onRowSelectedChange={noop} />
);
expect(screen.getByText('web-server_DataDisk')).toBeInTheDocument();
});
it("expands subscriptions when they're clicked", async () => {
const rows = createMockResourcePickerRows();
const promise = Promise.resolve();
const requestNestedRows = jest.fn().mockReturnValue(promise);
render(
<NestedResourceTable
rows={rows}
selectedRows={[]}
requestNestedRows={requestNestedRows}
onRowSelectedChange={noop}
/>
);
const expandButton = screen.getAllByLabelText('Expand')[1];
userEvent.click(expandButton);
expect(requestNestedRows).toBeCalledWith(
expect.objectContaining({
id: '/subscriptions/def-456',
name: 'Dev Subscription',
typeLabel: 'Subscription',
})
);
expect(screen.queryByText('Development')).not.toBeInTheDocument();
await act(() => promise);
expect(screen.getByText('Development')).toBeInTheDocument();
});
it('supports selecting variables', async () => {
const rows = createMockResourcePickerRows();
const promise = Promise.resolve();
const requestNestedRows = jest.fn().mockReturnValue(promise);
const onRowSelectedChange = jest.fn();
render(
<NestedResourceTable
rows={rows}
selectedRows={[]}
requestNestedRows={requestNestedRows}
onRowSelectedChange={onRowSelectedChange}
/>
);
const expandButton = screen.getAllByLabelText('Expand')[2];
userEvent.click(expandButton);
await act(() => promise);
const checkbox = screen.getByLabelText('$workspace');
userEvent.click(checkbox);
expect(onRowSelectedChange).toHaveBeenCalledWith(
expect.objectContaining({
id: '$workspace',
name: '$workspace',
}),
true
);
});
});
@@ -67,8 +67,7 @@ const NestedRow: React.FC<NestedRowProps> = ({ row, selectedRows, level, request
useEffect(() => {
// Assuming we don't have multi-select yet
const selectedRow = selectedRows[0];
const containsChild = selectedRow && !!findRow(row.children ?? [], selectedRow.id);
const containsChild = selectedRow && !!findRow(row.children ?? [], selectedRow.uri);
if (containsChild) {
setRowStatus('open');
@@ -203,7 +202,7 @@ const NestedEntry: React.FC<NestedEntryProps> = ({
<IconButton
className={styles.collapseButton}
name={isOpen ? 'angle-down' : 'angle-right'}
aria-label={isOpen ? 'Collapse' : 'Expand'}
aria-label={isOpen ? `Collapse ${entry.name}` : `Expand ${entry.name}`}
onClick={handleToggleCollapse}
id={entry.id}
/>
@@ -215,7 +214,13 @@ const NestedEntry: React.FC<NestedEntryProps> = ({
{isSelectable && (
<>
<Checkbox id={checkboxId} onChange={handleSelectedChanged} disabled={isDisabled} value={isSelected} />
<Checkbox
id={checkboxId}
onChange={handleSelectedChanged}
disabled={isDisabled}
value={isSelected}
className={styles.nestedRowCheckbox}
/>
<Space layout="inline" h={2} />
</>
)}
@@ -1,4 +1,4 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import React from 'react';
import ResourcePicker from '.';
@@ -10,127 +10,143 @@ import {
} from '../../__mocks__/resourcePickerRows';
const noResourceURI = '';
const singleSubscriptionSelectionURI = 'def-456';
const singleSubscriptionSelectionURI = '/subscriptions/def-456';
const singleResourceGroupSelectionURI = '/subscriptions/def-456/resourceGroups/dev-3';
const singleResourceSelectionURI =
'/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/db-serverproviders/Microsoft.Compute/virtualMachines/db-server';
'/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/db-server';
const createResourcePickerDataMock = () => {
return createMockResourcePickerData({
getSubscriptions: jest.fn().mockResolvedValue(createMockSubscriptions()),
getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue(createMockResourceGroupsBySubscription()),
getResourcesForResourceGroup: jest.fn().mockResolvedValue(mockResourcesByResourceGroup()),
});
};
describe('AzureMonitor ResourcePicker', () => {
const noop: any = () => {};
beforeEach(() => {
window.HTMLElement.prototype.scrollIntoView = function () {};
});
describe('when rendering the resource picker without a selection', () => {
it('should load subscriptions', async () => {
const resourePickerDataMock = createMockResourcePickerData({
getSubscriptions: jest.fn().mockResolvedValue(createMockSubscriptions()),
getResourceGroupsBySubscriptionId: jest.fn(),
getResourcesForResourceGroup: jest.fn(),
});
await act(() => {
render(
<ResourcePicker
templateVariables={[]}
resourcePickerData={resourePickerDataMock}
resourceURI={noResourceURI}
onCancel={noop}
onApply={noop}
/>
);
});
await waitFor(() => expect(screen.getByText('Primary Subscription')).toBeInTheDocument());
expect(resourePickerDataMock.getSubscriptions).toHaveBeenCalledTimes(1);
expect(resourePickerDataMock.getResourceGroupsBySubscriptionId).not.toHaveBeenCalled();
expect(resourePickerDataMock.getResourcesForResourceGroup).not.toHaveBeenCalled();
});
it('should pre-load subscriptions when there is no existing selection', async () => {
render(
<ResourcePicker
templateVariables={[]}
resourcePickerData={createResourcePickerDataMock()}
resourceURI={noResourceURI}
onCancel={noop}
onApply={noop}
/>
);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toBeInTheDocument();
expect(subscriptionCheckbox).not.toBeChecked();
const uncheckedCheckboxes = await screen.findAllByRole('checkbox', { checked: false });
expect(uncheckedCheckboxes.length).toBe(3);
});
describe('when rendering the resource picker with a subscription selected', () => {
it('should load subscriptions once', async () => {
const resourePickerDataMock = createMockResourcePickerData({
getSubscriptions: jest.fn().mockResolvedValue(createMockSubscriptions()),
getResourceGroupsBySubscriptionId: jest.fn(),
getResourcesForResourceGroup: jest.fn(),
});
await act(async () => {
render(
<ResourcePicker
templateVariables={[]}
resourcePickerData={resourePickerDataMock}
resourceURI={singleSubscriptionSelectionURI}
onCancel={noop}
onApply={noop}
/>
);
});
await waitFor(() => expect(screen.getByText('Primary Subscription')).toBeInTheDocument());
expect(resourePickerDataMock.getSubscriptions).toHaveBeenCalledTimes(1);
expect(resourePickerDataMock.getResourceGroupsBySubscriptionId).not.toHaveBeenCalled();
expect(resourePickerDataMock.getResourcesForResourceGroup).not.toHaveBeenCalled();
});
it('should show a subscription as selected if there is one saved', async () => {
render(
<ResourcePicker
templateVariables={[]}
resourcePickerData={createResourcePickerDataMock()}
resourceURI={singleSubscriptionSelectionURI}
onCancel={noop}
onApply={noop}
/>
);
const subscriptionCheckbox = await screen.findByLabelText('Dev Subscription');
expect(subscriptionCheckbox).toBeChecked();
});
describe('when rendering the resource picker with a resource group selected', () => {
it('should load subscriptions and resource groups for its parent subscription once', async () => {
const resourePickerDataMock = createMockResourcePickerData({
getSubscriptions: jest.fn().mockResolvedValue(createMockSubscriptions()),
getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue(createMockResourceGroupsBySubscription()),
getResourcesForResourceGroup: jest.fn(),
});
await act(async () => {
render(
<ResourcePicker
templateVariables={[]}
resourcePickerData={resourePickerDataMock}
resourceURI={singleResourceGroupSelectionURI}
onCancel={noop}
onApply={noop}
/>
);
});
await waitFor(() => expect(screen.getByText('Primary Subscription')).toBeInTheDocument());
expect(resourePickerDataMock.getSubscriptions).toHaveBeenCalledTimes(1);
expect(resourePickerDataMock.getResourceGroupsBySubscriptionId).toHaveBeenCalledTimes(1);
expect(resourePickerDataMock.getResourceGroupsBySubscriptionId).toHaveBeenLastCalledWith(
singleSubscriptionSelectionURI
);
expect(resourePickerDataMock.getResourcesForResourceGroup).not.toHaveBeenCalled();
});
it('should show a resource group as selected if there is one saved', async () => {
render(
<ResourcePicker
templateVariables={[]}
resourcePickerData={createResourcePickerDataMock()}
resourceURI={singleResourceGroupSelectionURI}
onCancel={noop}
onApply={noop}
/>
);
const resourceGroupCheckbox = await screen.findByLabelText('A Great Resource Group');
expect(resourceGroupCheckbox).toBeChecked();
});
describe('when rendering the resource picker with a resource selected', () => {
it('should load subscriptions, resource groups and resources once', async () => {
const resourePickerDataMock = createMockResourcePickerData({
getSubscriptions: jest.fn().mockResolvedValue(createMockSubscriptions()),
getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue(createMockResourceGroupsBySubscription()),
getResourcesForResourceGroup: jest.fn().mockResolvedValue(mockResourcesByResourceGroup()),
});
await act(async () => {
render(
<ResourcePicker
templateVariables={[]}
resourcePickerData={resourePickerDataMock}
resourceURI={singleResourceSelectionURI}
onCancel={noop}
onApply={noop}
/>
);
});
it('should show a resource as selected if there is one saved', async () => {
render(
<ResourcePicker
templateVariables={[]}
resourcePickerData={createResourcePickerDataMock()}
resourceURI={singleResourceSelectionURI}
onCancel={noop}
onApply={noop}
/>
);
await waitFor(() => expect(screen.getByText('Primary Subscription')).toBeInTheDocument());
expect(resourePickerDataMock.getSubscriptions).toHaveBeenCalledTimes(1);
expect(resourePickerDataMock.getResourceGroupsBySubscriptionId).toHaveBeenCalledTimes(1);
expect(resourePickerDataMock.getResourceGroupsBySubscriptionId).toHaveBeenLastCalledWith(
singleSubscriptionSelectionURI
);
expect(resourePickerDataMock.getResourcesForResourceGroup).toHaveBeenCalledTimes(1);
expect(resourePickerDataMock.getResourcesForResourceGroup).toHaveBeenLastCalledWith(
singleResourceGroupSelectionURI
);
});
const resourceCheckbox = await screen.findByLabelText('db-server');
expect(resourceCheckbox).toBeChecked();
});
it('should be able to expand a subscription when clicked and reveal resource groups', async () => {
render(
<ResourcePicker
templateVariables={[]}
resourcePickerData={createResourcePickerDataMock()}
resourceURI={noResourceURI}
onCancel={noop}
onApply={noop}
/>
);
const expandSubscriptionButton = await screen.findByLabelText('Expand Primary Subscription');
expect(expandSubscriptionButton).toBeInTheDocument();
expect(screen.queryByLabelText('A Great Resource Group')).not.toBeInTheDocument();
expandSubscriptionButton.click();
expect(await screen.findByLabelText('A Great Resource Group')).toBeInTheDocument();
});
it('should call onApply with a new subscription uri when a user selects it', async () => {
const onApply = jest.fn();
render(
<ResourcePicker
templateVariables={[]}
resourcePickerData={createResourcePickerDataMock()}
resourceURI={noResourceURI}
onCancel={noop}
onApply={onApply}
/>
);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toBeInTheDocument();
expect(subscriptionCheckbox).not.toBeChecked();
subscriptionCheckbox.click();
const applyButton = screen.getByRole('button', { name: 'Apply' });
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith('/subscriptions/def-123');
});
it('should call onApply with a template variable when a user selects it', async () => {
const onApply = jest.fn();
render(
<ResourcePicker
templateVariables={['$workspace']}
resourcePickerData={createResourcePickerDataMock()}
resourceURI={noResourceURI}
onCancel={noop}
onApply={onApply}
/>
);
const expandButton = await screen.findByLabelText('Expand Template variables');
expandButton.click();
const workSpaceCheckbox = await screen.findByLabelText('$workspace');
workSpaceCheckbox.click();
const applyButton = screen.getByRole('button', { name: 'Apply' });
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith('$workspace');
});
});
@@ -10,6 +10,7 @@ import NestedResourceTable from './NestedResourceTable';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from './types';
import { addResources, findRow, parseResourceURI } from './utils';
const TEMPLATE_VARIABLE_GROUP_ID = '$$grafana-templateVariables$$';
interface ResourcePickerProps {
resourcePickerData: ResourcePickerData;
resourceURI: string | undefined;
@@ -31,12 +32,12 @@ const ResourcePicker = ({
type LoadingStatus = 'NotStarted' | 'Started' | 'Done';
const [loadingStatus, setLoadingStatus] = useState<LoadingStatus>('NotStarted');
const [azureRows, setAzureRows] = useState<ResourceRowGroup>([]);
const [internalSelected, setInternalSelected] = useState<string | undefined>(resourceURI);
const [internalSelectedURI, setInternalSelectedURI] = useState<string | undefined>(resourceURI);
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
// Sync the resourceURI prop to internal state
useEffect(() => {
setInternalSelected(resourceURI);
setInternalSelectedURI(resourceURI);
}, [resourceURI]);
// Request initial data on first mount
@@ -46,13 +47,13 @@ const ResourcePicker = ({
try {
setLoadingStatus('Started');
let resources = await resourcePickerData.getSubscriptions();
if (!internalSelected) {
if (!internalSelectedURI) {
setAzureRows(resources);
setLoadingStatus('Done');
return;
}
const parsedURI = parseResourceURI(internalSelected ?? '');
const parsedURI = parseResourceURI(internalSelectedURI ?? '');
if (parsedURI) {
const resourceGroupURI = `/subscriptions/${parsedURI.subscriptionID}/resourceGroups/${parsedURI.resourceGroup}`;
@@ -61,7 +62,7 @@ const ResourcePicker = ({
const resourceGroups = await resourcePickerData.getResourceGroupsBySubscriptionId(
parsedURI.subscriptionID
);
resources = addResources(resources, parsedURI.subscriptionID, resourceGroups);
resources = addResources(resources, `/subscriptions/${parsedURI.subscriptionID}`, resourceGroups);
}
// if a resource was previously selected, but the resources under the parent resource group have not been loaded yet
@@ -80,16 +81,16 @@ const ResourcePicker = ({
loadInitialData();
}
}, [resourcePickerData, internalSelected, azureRows, loadingStatus]);
}, [resourcePickerData, internalSelectedURI, azureRows, loadingStatus]);
const rows = useMemo(() => {
const templateVariableRow = resourcePickerData.transformVariablesToRow(templateVariables);
const templateVariableRow = transformVariablesToRow(templateVariables);
return templateVariables.length ? [...azureRows, templateVariableRow] : azureRows;
}, [resourcePickerData, azureRows, templateVariables]);
}, [azureRows, templateVariables]);
// Map the selected item into an array of rows
const selectedResourceRows = useMemo(() => {
const found = internalSelected && findRow(rows, internalSelected);
const found = internalSelectedURI && findRow(rows, internalSelectedURI);
return found
? [
{
@@ -98,7 +99,7 @@ const ResourcePicker = ({
},
]
: [];
}, [internalSelected, rows]);
}, [internalSelectedURI, rows]);
// Request resources for a expanded resource group
const requestNestedRows = useCallback(
@@ -110,7 +111,7 @@ const ResourcePicker = ({
// template variable group, though that shouldn't happen in practice
if (
resourceGroupOrSubscription.children?.length ||
resourceGroupOrSubscription.id === ResourcePickerData.templateVariableGroupID
resourceGroupOrSubscription.uri === TEMPLATE_VARIABLE_GROUP_ID
) {
return;
}
@@ -121,7 +122,7 @@ const ResourcePicker = ({
? await resourcePickerData.getResourceGroupsBySubscriptionId(resourceGroupOrSubscription.id)
: await resourcePickerData.getResourcesForResourceGroup(resourceGroupOrSubscription.id);
const newRows = addResources(azureRows, resourceGroupOrSubscription.id, rows);
const newRows = addResources(azureRows, resourceGroupOrSubscription.uri, rows);
setAzureRows(newRows);
} catch (error) {
@@ -132,14 +133,13 @@ const ResourcePicker = ({
[resourcePickerData, azureRows]
);
// Select
const handleSelectionChanged = useCallback((row: ResourceRow, isSelected: boolean) => {
isSelected ? setInternalSelected(row.id) : setInternalSelected(undefined);
isSelected ? setInternalSelectedURI(row.uri) : setInternalSelectedURI(undefined);
}, []);
const handleApply = useCallback(() => {
onApply(internalSelected);
}, [internalSelected, onApply]);
onApply(internalSelectedURI);
}, [internalSelectedURI, onApply]);
return (
<div>
@@ -211,3 +211,20 @@ const getStyles = (theme: GrafanaTheme2) => ({
color: theme.colors.text.secondary,
}),
});
function transformVariablesToRow(templateVariables: string[]): ResourceRow {
return {
id: TEMPLATE_VARIABLE_GROUP_ID,
uri: TEMPLATE_VARIABLE_GROUP_ID,
name: 'Template variables',
type: ResourceRowType.VariableGroup,
typeLabel: 'Variables',
children: templateVariables.map((v) => ({
id: v,
uri: v,
name: v,
type: ResourceRowType.Variable,
typeLabel: 'Variable',
})),
};
}
@@ -64,6 +64,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}),
nestedRowCheckbox: css({
zIndex: 0,
}),
});
export default getStyles;
@@ -7,7 +7,8 @@ export enum ResourceRowType {
}
export interface ResourceRow {
id: string;
id: string; // azure's raw data id usually passes along a uri (except in the case of subscriptions), to make things less confusing for ourselves we parse the id string out of the uri or vice versa
uri: string; // ex: /subscriptions/subid123
name: string;
type: ResourceRowType;
typeLabel: string;
@@ -26,14 +26,14 @@ export function isGUIDish(input: string) {
return !!input.match(/^[A-Z0-9]+/i);
}
export function findRow(rows: ResourceRowGroup, id: string): ResourceRow | undefined {
export function findRow(rows: ResourceRowGroup, uri: string): ResourceRow | undefined {
for (const row of rows) {
if (row.id.toLowerCase() === id.toLowerCase()) {
if (row.uri.toLowerCase() === uri.toLowerCase()) {
return row;
}
if (row.children) {
const result = findRow(row.children, id);
const result = findRow(row.children, uri);
if (result) {
return result;
@@ -44,9 +44,9 @@ export function findRow(rows: ResourceRowGroup, id: string): ResourceRow | undef
return undefined;
}
export function addResources(rows: ResourceRowGroup, targetResourceGroupID: string, newResources: ResourceRowGroup) {
export function addResources(rows: ResourceRowGroup, targetParentId: string, newResources: ResourceRowGroup) {
return produce(rows, (draftState) => {
const draftRow = findRow(draftState, targetResourceGroupID);
const draftRow = findRow(draftState, targetParentId);
if (!draftRow) {
// This case shouldn't happen often because we're usually coming here from a resource we already have
@@ -32,7 +32,7 @@
"large": "img/logo.jpg"
},
"links": [
{ "name": "Learn more", "url": "https://github.com/grafana/azure-monitor-datasource" },
{ "name": "Learn more", "url": "https://grafana.com/docs/grafana/latest/datasources/azuremonitor/" },
{ "name": "Apache License", "url": "https://github.com/grafana/azure-monitor-datasource/blob/master/LICENSE" }
],
"screenshots": [
@@ -40,8 +40,7 @@
{ "name": "Azure Monitor Network", "path": "img/azure_monitor_network.png" },
{ "name": "Azure Monitor CPU", "path": "img/azure_monitor_cpu.png" }
],
"version": "0.3.0",
"updated": "2018-12-06"
"version": "1.0.0"
},
"dependencies": {
@@ -4,129 +4,246 @@ import {
createMockARGSubscriptionResponse,
} from '../__mocks__/argResourcePickerResponse';
import { createMockInstanceSetttings } from '../__mocks__/instanceSettings';
import { ResourceRowType } from '../components/ResourcePicker/types';
import ResourcePickerData from './resourcePickerData';
import { AzureGraphResponse } from '../types';
const instanceSettings = createMockInstanceSetttings();
const resourcePickerData = new ResourcePickerData(instanceSettings);
let postResource: jest.Mock;
const createResourcePickerData = (responses: AzureGraphResponse[]) => {
const instanceSettings = createMockInstanceSetttings();
const resourcePickerData = new ResourcePickerData(instanceSettings);
const postResource = jest.fn();
responses.forEach((res) => {
postResource.mockResolvedValueOnce(res);
});
resourcePickerData.postResource = postResource;
return { resourcePickerData, postResource };
};
describe('AzureMonitor resourcePickerData', () => {
describe('getSubscriptions', () => {
beforeEach(() => {
postResource = jest.fn().mockResolvedValue(createMockARGSubscriptionResponse());
resourcePickerData.postResource = postResource;
});
it('calls ARG API', async () => {
it('makes 1 call to ARG with the correct path and query arguments', async () => {
const mockResponse = createMockARGSubscriptionResponse();
const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]);
await resourcePickerData.getSubscriptions();
expect(postResource).toHaveBeenCalled();
const argQuery = postResource.mock.calls[0][1].query;
expect(postResource).toBeCalledTimes(1);
const firstCall = postResource.mock.calls[0];
const [path, postBody] = firstCall;
expect(path).toEqual('resourcegraph/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01');
expect(postBody.query).toContain("where type == 'microsoft.resources/subscriptions'");
});
it('returns formatted subscriptions', async () => {
const mockResponse = createMockARGSubscriptionResponse();
const { resourcePickerData } = createResourcePickerData([mockResponse]);
expect(argQuery).toContain(`where type == 'microsoft.resources/subscriptions'`);
const subscriptions = await resourcePickerData.getSubscriptions();
expect(subscriptions.length).toEqual(6);
expect(subscriptions[0]).toEqual({
id: '1',
name: 'Primary Subscription',
type: 'Subscription',
typeLabel: 'Subscription',
uri: '/subscriptions/1',
children: [],
});
});
describe('when there is more than one page', () => {
beforeEach(() => {
const response1 = {
...createMockARGSubscriptionResponse(),
$skipToken: 'aaa',
};
const response2 = createMockARGSubscriptionResponse();
postResource = jest.fn();
postResource.mockResolvedValueOnce(response1);
postResource.mockResolvedValueOnce(response2);
resourcePickerData.postResource = postResource;
});
it('makes multiple requests when arg returns a skipToken and passes the right skipToken to each subsequent call', async () => {
const response1 = {
...createMockARGSubscriptionResponse(),
$skipToken: 'skipfirst100',
};
const response2 = createMockARGSubscriptionResponse();
const { resourcePickerData, postResource } = createResourcePickerData([response1, response2]);
it('should requests additional pages', async () => {
await resourcePickerData.getSubscriptions();
expect(postResource).toHaveBeenCalledTimes(2);
});
await resourcePickerData.getSubscriptions();
it('should use the skipToken of the previous page', async () => {
await resourcePickerData.getSubscriptions();
const secondCall = postResource.mock.calls[1];
expect(secondCall[1]).toMatchObject({ options: { $skipToken: 'aaa', resultFormat: 'objectArray' } });
expect(postResource).toHaveBeenCalledTimes(2);
const secondCall = postResource.mock.calls[1];
const [_, postBody] = secondCall;
expect(postBody.options.$skipToken).toEqual('skipfirst100');
});
it('returns a concatenates a formatted array of subscriptions when there are multiple pages from arg', async () => {
const response1 = {
...createMockARGSubscriptionResponse(),
$skipToken: 'skipfirst100',
};
const response2 = createMockARGSubscriptionResponse();
const { resourcePickerData } = createResourcePickerData([response1, response2]);
const subscriptions = await resourcePickerData.getSubscriptions();
expect(subscriptions.length).toEqual(12);
expect(subscriptions[0]).toEqual({
id: '1',
name: 'Primary Subscription',
type: 'Subscription',
typeLabel: 'Subscription',
uri: '/subscriptions/1',
children: [],
});
});
it('throws an error if it does not recieve data from arg', async () => {
const mockResponse = { data: [] };
const { resourcePickerData } = createResourcePickerData([mockResponse]);
try {
await resourcePickerData.getSubscriptions();
throw Error('expected getSubscriptions to fail but it succeeded');
} catch (err) {
expect(err.message).toEqual('unable to fetch subscriptions');
}
});
});
describe('getResourcesForResourceGroup', () => {
beforeEach(() => {
postResource = jest.fn().mockResolvedValue(createMockARGResourceGroupsResponse());
resourcePickerData.postResource = postResource;
});
it('calls ARG API', async () => {
describe('getResourceGroupsBySubscriptionId', () => {
it('makes 1 call to ARG with the correct path and query arguments', async () => {
const mockResponse = createMockARGResourceGroupsResponse();
const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]);
await resourcePickerData.getResourceGroupsBySubscriptionId('123');
expect(postResource).toHaveBeenCalled();
const argQuery = postResource.mock.calls[0][1].query;
expect(postResource).toBeCalledTimes(1);
const firstCall = postResource.mock.calls[0];
const [path, postBody] = firstCall;
expect(path).toEqual('resourcegraph/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01');
expect(postBody.query).toContain("type == 'microsoft.resources/subscriptions/resourcegroups'");
expect(postBody.query).toContain("where subscriptionId == '123'");
});
it('returns formatted resourceGroups', async () => {
const mockResponse = createMockARGResourceGroupsResponse();
const { resourcePickerData } = createResourcePickerData([mockResponse]);
expect(argQuery).toContain(`| where subscriptionId == '123'`);
const resourceGroups = await resourcePickerData.getResourceGroupsBySubscriptionId('123');
expect(resourceGroups.length).toEqual(6);
expect(resourceGroups[0]).toEqual({
id: 'prod',
name: 'Production',
type: 'ResourceGroup',
typeLabel: 'Resource Group',
uri: '/subscriptions/abc-123/resourceGroups/prod',
children: [],
});
});
describe('when there is more than one page', () => {
beforeEach(() => {
const response1 = {
...createMockARGResourceGroupsResponse(),
$skipToken: 'aaa',
};
const response2 = createMockARGResourceGroupsResponse();
postResource = jest.fn();
postResource.mockResolvedValueOnce(response1);
postResource.mockResolvedValueOnce(response2);
resourcePickerData.postResource = postResource;
});
it('makes multiple requests when it is returned a skip token', async () => {
const response1 = {
...createMockARGResourceGroupsResponse(),
$skipToken: 'skipfirst100',
};
const response2 = createMockARGResourceGroupsResponse();
const { resourcePickerData, postResource } = createResourcePickerData([response1, response2]);
it('should requests additional pages', async () => {
await resourcePickerData.getResourceGroupsBySubscriptionId('123');
expect(postResource).toHaveBeenCalledTimes(2);
});
await resourcePickerData.getResourceGroupsBySubscriptionId('123');
it('should use the skipToken of the previous page', async () => {
await resourcePickerData.getResourceGroupsBySubscriptionId('123');
const secondCall = postResource.mock.calls[1];
expect(secondCall[1]).toMatchObject({ options: { $skipToken: 'aaa', resultFormat: 'objectArray' } });
expect(postResource).toHaveBeenCalledTimes(2);
const secondCall = postResource.mock.calls[1];
const [_, postBody] = secondCall;
expect(postBody.options.$skipToken).toEqual('skipfirst100');
});
it('returns a concatonized and formatted array of resourceGroups when there are multiple pages', async () => {
const response1 = {
...createMockARGResourceGroupsResponse(),
$skipToken: 'skipfirst100',
};
const response2 = createMockARGResourceGroupsResponse();
const { resourcePickerData } = createResourcePickerData([response1, response2]);
const resourceGroups = await resourcePickerData.getResourceGroupsBySubscriptionId('123');
expect(resourceGroups.length).toEqual(12);
expect(resourceGroups[0]).toEqual({
id: 'prod',
name: 'Production',
type: 'ResourceGroup',
typeLabel: 'Resource Group',
uri: '/subscriptions/abc-123/resourceGroups/prod',
children: [],
});
});
it('throws an error if it does not receive data', async () => {
const mockResponse = { data: [] };
const { resourcePickerData } = createResourcePickerData([mockResponse]);
try {
await resourcePickerData.getResourceGroupsBySubscriptionId('123');
throw Error('expected getSubscriptions to fail but it succeeded');
} catch (err) {
expect(err.message).toEqual('unable to fetch resource groups');
}
});
it('throws an error if it recieves data with a malformed uri', async () => {
const mockResponse = {
data: [
{
resourceGroupURI: '/a-differently-formatted/uri/than/the/type/we/planned/to/parse',
resourceGroupName: 'Production',
},
],
};
const { resourcePickerData } = createResourcePickerData([mockResponse]);
try {
await resourcePickerData.getResourceGroupsBySubscriptionId('123');
throw Error('expected getResourceGroupsBySubscriptionId to fail but it succeeded');
} catch (err) {
expect(err.message).toEqual('unable to fetch resource groups');
}
});
});
describe('getResourcesForResourceGroup', () => {
const resourceRow = {
id: '/subscriptions/def-456/resourceGroups/dev',
name: 'Dev',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource group',
};
it('makes 1 call to ARG with the correct path and query arguments', async () => {
const mockResponse = createARGResourcesResponse();
const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]);
await resourcePickerData.getResourcesForResourceGroup('dev');
beforeEach(() => {
postResource = jest.fn().mockResolvedValue(createARGResourcesResponse());
resourcePickerData.postResource = postResource;
expect(postResource).toBeCalledTimes(1);
const firstCall = postResource.mock.calls[0];
const [path, postBody] = firstCall;
expect(path).toEqual('resourcegraph/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01');
expect(postBody.query).toContain('resources');
expect(postBody.query).toContain('where id hasprefix "dev"');
});
it('requests resources for the specified resource row', async () => {
await resourcePickerData.getResourcesForResourceGroup(resourceRow.id);
expect(postResource).toHaveBeenCalled();
const argQuery = postResource.mock.calls[0][1].query;
expect(argQuery).toContain(resourceRow.id);
});
it('returns formatted resources', async () => {
const results = await resourcePickerData.getResourcesForResourceGroup(resourceRow.id);
const mockResponse = createARGResourcesResponse();
const { resourcePickerData } = createResourcePickerData([mockResponse]);
expect(results.map((v) => v.id)).toEqual([
'/subscriptions/def-456/resourceGroups/dev/providers/Microsoft.Compute/virtualMachines/web-server',
'/subscriptions/def-456/resourceGroups/dev/providers/Microsoft.Compute/disks/web-server_DataDisk',
'/subscriptions/def-456/resourceGroups/dev/providers/Microsoft.Compute/virtualMachines/db-server',
'/subscriptions/def-456/resourceGroups/dev/providers/Microsoft.Compute/disks/db-server_DataDisk',
]);
const resources = await resourcePickerData.getResourcesForResourceGroup('dev');
results.forEach((v) => expect(v.type).toEqual(ResourceRowType.Resource));
expect(resources.length).toEqual(4);
expect(resources[0]).toEqual({
id: 'web-server',
name: 'web-server',
type: 'Resource',
location: 'North Europe',
resourceGroupName: 'dev',
typeLabel: 'Microsoft.Compute/virtualMachines',
uri: '/subscriptions/def-456/resourceGroups/dev/providers/Microsoft.Compute/virtualMachines/web-server',
});
});
it('throws an error if it recieves data with a malformed uri', async () => {
const mockResponse = {
data: [
{
id: '/a-differently-formatted/uri/than/the/type/we/planned/to/parse',
name: 'web-server',
type: 'Microsoft.Compute/virtualMachines',
resourceGroup: 'dev',
subscriptionId: 'def-456',
location: 'northeurope',
},
],
};
const { resourcePickerData } = createResourcePickerData([mockResponse]);
try {
await resourcePickerData.getResourcesForResourceGroup('dev');
throw Error('expected getResourcesForResourceGroup to fail but it succeeded');
} catch (err) {
expect(err.message).toEqual('unable to fetch resource details');
}
});
});
});
@@ -7,7 +7,7 @@ import {
logsSupportedResourceTypesKusto,
resourceTypeDisplayNames,
} from '../azureMetadata';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from '../components/ResourcePicker/types';
import { ResourceRowGroup, ResourceRowType } from '../components/ResourcePicker/types';
import { parseResourceURI } from '../components/ResourcePicker/utils';
import {
AzureDataSourceJsonData,
@@ -31,8 +31,6 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
this.resourcePath = `${routeNames.resourceGraph}`;
}
static readonly templateVariableGroupID = '$$grafana-templateVariables$$';
async getSubscriptions(): Promise<ResourceRowGroup> {
const query = `
resources
@@ -59,7 +57,7 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
}
const resourceResponse = await this.makeResourceGraphRequest<RawAzureSubscriptionItem[]>(query, 1, options);
if (!resourceResponse.data.length) {
throw new Error('unable to fetch resource details');
throw new Error('unable to fetch subscriptions');
}
resources = resources.concat(resourceResponse.data);
$skipToken = resourceResponse.$skipToken;
@@ -69,13 +67,14 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
return resources.map((subscription) => ({
name: subscription.subscriptionName,
id: subscription.subscriptionId,
uri: `/subscriptions/${subscription.subscriptionId}`,
typeLabel: 'Subscription',
type: ResourceRowType.Subscription,
children: [],
}));
}
async getResourceGroupsBySubscriptionId(subscriptionId: string) {
async getResourceGroupsBySubscriptionId(subscriptionId: string): Promise<ResourceRowGroup> {
const query = `
resources
| join kind=inner (
@@ -89,7 +88,7 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
| summarize count() by resourceGroupName, resourceGroupURI
| order by resourceGroupURI asc`;
let resources: RawAzureResourceGroupItem[] = [];
let resourceGroups: RawAzureResourceGroupItem[] = [];
let allFetched = false;
let $skipToken = undefined;
while (!allFetched) {
@@ -102,32 +101,54 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
}
const resourceResponse = await this.makeResourceGraphRequest<RawAzureResourceGroupItem[]>(query, 1, options);
if (!resourceResponse.data.length) {
throw new Error('unable to fetch resource details');
throw new Error('unable to fetch resource groups');
}
resources = resources.concat(resourceResponse.data);
resourceGroups = resourceGroups.concat(resourceResponse.data);
$skipToken = resourceResponse.$skipToken;
allFetched = !$skipToken;
}
return resources.map((r) => ({
name: r.resourceGroupName,
id: r.resourceGroupURI,
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
}));
return resourceGroups.map((r) => {
const parsedUri = parseResourceURI(r.resourceGroupURI);
if (!parsedUri || !parsedUri.resourceGroup) {
throw new Error('unable to fetch resource groups');
}
return {
name: r.resourceGroupName,
uri: r.resourceGroupURI,
id: parsedUri.resourceGroup,
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
};
});
}
async getResourcesForResourceGroup(resourceGroupId: string) {
async getResourcesForResourceGroup(resourceGroupId: string): Promise<ResourceRowGroup> {
const { data: response } = await this.makeResourceGraphRequest<RawAzureResourceItem[]>(`
resources
| where id hasprefix "${resourceGroupId}"
| where type in (${logsSupportedResourceTypesKusto}) and location in (${logsSupportedLocationsKusto})
`);
return formatResourceGroupChildren(response);
return response.map((item) => {
const parsedUri = parseResourceURI(item.id);
if (!parsedUri || !parsedUri.resource) {
throw new Error('unable to fetch resource details');
}
return {
name: item.name,
id: parsedUri.resource,
uri: item.id,
resourceGroupName: item.resourceGroup,
type: ResourceRowType.Resource,
typeLabel: resourceTypeDisplayNames[item.type] || item.type,
location: locationDisplayNames[item.location] || item.location,
};
});
}
// used to make the select resource button that launches the resource picker show a nicer file path to users
async getResourceURIDisplayProperties(resourceURI: string): Promise<AzureResourceSummaryItem> {
const { subscriptionID, resourceGroup } = parseResourceURI(resourceURI) ?? {};
@@ -206,30 +227,4 @@ export default class ResourcePickerData extends DataSourceWithBackend<AzureMonit
throw error;
}
}
transformVariablesToRow(templateVariables: string[]): ResourceRow {
return {
id: ResourcePickerData.templateVariableGroupID,
name: 'Template variables',
type: ResourceRowType.VariableGroup,
typeLabel: 'Variables',
children: templateVariables.map((v) => ({
id: v,
name: v,
type: ResourceRowType.Variable,
typeLabel: 'Variable',
})),
};
}
}
function formatResourceGroupChildren(rawData: RawAzureResourceItem[]): ResourceRowGroup {
return rawData.map((item) => ({
name: item.name,
id: item.id,
resourceGroupName: item.resourceGroup,
type: ResourceRowType.Resource,
typeLabel: resourceTypeDisplayNames[item.type] || item.type,
location: locationDisplayNames[item.location] || item.location,
}));
}

Some files were not shown because too many files have changed in this diff Show More