diff --git a/.drone.yml b/.drone.yml
index a64dddc89aa..81b2be1109e 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -901,8 +901,8 @@ steps:
- commands:
- printenv GCP_KEY | base64 -d > /tmp/gcpkey.json
- gcloud auth activate-service-account --key-file=/tmp/gcpkey.json
- - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/canary && gsutil
- -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/canary
+ - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/canary || true
+ && gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/canary
depends_on:
- build-storybook
- end-to-end-tests-dashboards-suite
@@ -1594,10 +1594,10 @@ steps:
- commands:
- printenv GCP_KEY | base64 -d > /tmp/gcpkey.json
- gcloud auth activate-service-account --key-file=/tmp/gcpkey.json
- - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/latest && gsutil
- -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/latest
- - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/${DRONE_TAG} &&
- gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/${DRONE_TAG}
+ - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/latest || true
+ && gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/latest
+ - gsutil -m rm -r gs://$${PRERELEASE_BUCKET}/artifacts/storybook/${DRONE_TAG} ||
+ true && gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${PRERELEASE_BUCKET}/artifacts/storybook/${DRONE_TAG}
depends_on:
- build-storybook
- end-to-end-tests-dashboards-suite
@@ -2934,12 +2934,14 @@ steps:
image: byrnedo/alpine-curl:0.1.8
name: grabpl
- commands:
- - ./bin/grabpl artifacts publish --security --tag ${TAG} --src-bucket grafana-prerelease
+ - ./bin/grabpl artifacts publish --security --tag ${TAG} --src-bucket $${PRERELEASE_BUCKET}
depends_on:
- grabpl
environment:
GCP_KEY:
from_secret: gcp_key
+ PRERELEASE_BUCKET:
+ from_secret: prerelease_bucket
image: grafana/grafana-ci-deploy:1.3.1
name: publish-artifacts
trigger:
@@ -2970,12 +2972,14 @@ steps:
image: byrnedo/alpine-curl:0.1.8
name: grabpl
- commands:
- - ./bin/grabpl artifacts publish --tag ${TAG} --src-bucket grafana-prerelease
+ - ./bin/grabpl artifacts publish --tag ${TAG} --src-bucket $${PRERELEASE_BUCKET}
depends_on:
- grabpl
environment:
GCP_KEY:
from_secret: gcp_key
+ PRERELEASE_BUCKET:
+ from_secret: prerelease_bucket
image: grafana/grafana-ci-deploy:1.3.1
name: publish-artifacts
trigger:
@@ -4448,6 +4452,6 @@ kind: secret
name: gcp_upload_artifacts_key
---
kind: signature
-hmac: ec763e70ae08f79845dd4a59c56e6d08ef8e6b39a1f59663aabcc6d9fe758287
+hmac: 9b87d4bb17d422973023c6ad1a59980c37832ae60ba6bb0d11ee024ea1e35b1a
...
diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml
index 55dc5c8da02..97042213f2c 100644
--- a/.github/workflows/backport.yml
+++ b/.github/workflows/backport.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
repository: "grafana/grafana-github-actions"
path: ./actions
diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml
index f0aface7844..619ac66bd71 100644
--- a/.github/workflows/bump-version.yml
+++ b/.github/workflows/bump-version.yml
@@ -50,7 +50,7 @@ jobs:
example: 7.4.3 or 7.4.3-beta1"
exit 1
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Set intermedia variables
id: intermedia
@@ -69,7 +69,7 @@ jobs:
exit 1
- name: Checkout Actions
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
repository: "grafana/grafana-github-actions"
path: ./actions
diff --git a/.github/workflows/close-milestone.yml b/.github/workflows/close-milestone.yml
index a495eab7a8b..e798f0cd4bf 100644
--- a/.github/workflows/close-milestone.yml
+++ b/.github/workflows/close-milestone.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
repository: "grafana/grafana-github-actions"
path: ./actions
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index c1578b4840f..6fd5101abc8 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml
index 7b5c492590f..ac5a03f9d5c 100644
--- a/.github/workflows/commands.yml
+++ b/.github/workflows/commands.yml
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
repository: "grafana/grafana-github-actions"
path: ./actions
diff --git a/.github/workflows/detect-breaking-changes-build.yml b/.github/workflows/detect-breaking-changes-build.yml
index 87096eaf159..8e76bbe441b 100644
--- a/.github/workflows/detect-breaking-changes-build.yml
+++ b/.github/workflows/detect-breaking-changes-build.yml
@@ -11,7 +11,7 @@ jobs:
working-directory: './pr'
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
with:
path: './pr'
@@ -51,7 +51,7 @@ jobs:
working-directory: './base'
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
with:
path: './base'
ref: ${{ github.event.pull_request.base.ref }}
@@ -92,7 +92,7 @@ jobs:
GITHUB_STEP_NUMBER: 7
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Get built packages from pr
uses: actions/download-artifact@v2
diff --git a/.github/workflows/detect-breaking-changes-report.yml b/.github/workflows/detect-breaking-changes-report.yml
index fe5f2f1f490..fec1f33b93d 100644
--- a/.github/workflows/detect-breaking-changes-report.yml
+++ b/.github/workflows/detect-breaking-changes-report.yml
@@ -14,7 +14,7 @@ jobs:
ARTIFACT_NAME: 'levitate'
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: 'Download artifact'
uses: actions/github-script@v6
diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml
index 9f1f1fe31c5..f0223cfdcca 100644
--- a/.github/workflows/github-release.yml
+++ b/.github/workflows/github-release.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
repository: "grafana/grafana-github-actions"
path: ./actions
diff --git a/.github/workflows/metrics-collector.yml b/.github/workflows/metrics-collector.yml
index 84dec4e204a..874ccc4c3e7 100644
--- a/.github/workflows/metrics-collector.yml
+++ b/.github/workflows/metrics-collector.yml
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
repository: "grafana/grafana-github-actions"
path: ./actions
diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml
index 16e00f6d094..bf41c507509 100644
--- a/.github/workflows/pr-checks.yml
+++ b/.github/workflows/pr-checks.yml
@@ -20,7 +20,7 @@ jobs:
if: github.event.pull_request.draft == false
steps:
- name: Checkout Actions
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
repository: "grafana/grafana-github-actions"
path: ./actions
diff --git a/.github/workflows/pr-codeql-analysis-go.yml b/.github/workflows/pr-codeql-analysis-go.yml
index 6e6bd524cc7..d80c62f4f5c 100644
--- a/.github/workflows/pr-codeql-analysis-go.yml
+++ b/.github/workflows/pr-codeql-analysis-go.yml
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
diff --git a/.github/workflows/pr-codeql-analysis-javascript.yml b/.github/workflows/pr-codeql-analysis-javascript.yml
index 943bbfc1543..9fc371a61ad 100644
--- a/.github/workflows/pr-codeql-analysis-javascript.yml
+++ b/.github/workflows/pr-codeql-analysis-javascript.yml
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
diff --git a/.github/workflows/pr-codeql-analysis-python.yml b/.github/workflows/pr-codeql-analysis-python.yml
index 4f887526812..d9fc18b4f68 100644
--- a/.github/workflows/pr-codeql-analysis-python.yml
+++ b/.github/workflows/pr-codeql-analysis-python.yml
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
diff --git a/.github/workflows/pr-commands.yml b/.github/workflows/pr-commands.yml
index 7f47cfd8e92..ecd7361c550 100644
--- a/.github/workflows/pr-commands.yml
+++ b/.github/workflows/pr-commands.yml
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
repository: "grafana/grafana-github-actions"
path: ./actions
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index cc7d6bf4953..742dc7f9bac 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v1
+ - uses: actions/checkout@v3
- run: git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync
- name: generate-packages-docs
uses: actions/setup-node@v2.5.1
diff --git a/.github/workflows/remove-milestone.yml b/.github/workflows/remove-milestone.yml
index ac2504a45f8..78305864e09 100644
--- a/.github/workflows/remove-milestone.yml
+++ b/.github/workflows/remove-milestone.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
repository: "grafana/grafana-github-actions"
path: ./actions
diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml
index 9ea0da2388e..fe03bdeed01 100644
--- a/.github/workflows/update-changelog.yml
+++ b/.github/workflows/update-changelog.yml
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
repository: "grafana/grafana-github-actions"
path: ./actions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7f97bec4e9..db42f22ca94 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,30 @@
+
+
+# 8.5.0-beta1 (2022-04-06)
+
+### Features and enhancements
+
+- Add config option to enable/disable reporting. (Enterprise)
+- **Alerting:** Accurately set value for prom-compatible APIs. [#47216](https://github.com/grafana/grafana/pull/47216), [@gotjosh](https://github.com/gotjosh)
+- **Alerting:** Provisioning API - Notification Policies. [#46755](https://github.com/grafana/grafana/pull/46755), [@alexweav](https://github.com/alexweav)
+- **Analytics:** Enable grafana and plugin update checks to be operated independently. [#46352](https://github.com/grafana/grafana/pull/46352), [@wbrowne](https://github.com/wbrowne)
+- **Azure Monitor:** Add support for multiple template variables in resource picker. [#46215](https://github.com/grafana/grafana/pull/46215), [@sarahzinger](https://github.com/sarahzinger)
+- **Caching:** Add separate TTL for resources cache. (Enterprise)
+- **Caching:** add support for TLS configuration for Redis Cluster. (Enterprise)
+- **NewsPanel:** Remove Use Proxy option and update documentation with recommendations. [#47189](https://github.com/grafana/grafana/pull/47189), [@joshhunt](https://github.com/joshhunt)
+- **OAuth:** Sync GitHub OAuth user name to Grafana if it's set. [#45438](https://github.com/grafana/grafana/pull/45438), [@pallxk](https://github.com/pallxk)
+
+### Bug fixes
+
+- **Plugins:** Fix Default Nav URL for dashboard includes. [#47143](https://github.com/grafana/grafana/pull/47143), [@wbrowne](https://github.com/wbrowne)
+
+### Breaking changes
+
+When user is using Github OAuth, GitHub login is showed as both Grafana login and name. Now the GitHub name is showed as Grafana name, and GitHub login is showed as Grafana Login. Issue [#45438](https://github.com/grafana/grafana/issues/45438)
+
+The meaning of the default data source has now changed from being a persisted property in a panel. Before when you selected the default data source for a panel and later changed the default data source to another data source it would change all panels who were configured to use the default data source. From now on the default data source is just the default for new panels and changing the default will not impact any currently saved dashboards. Issue [#45132](https://github.com/grafana/grafana/issues/45132)
+
+
# 8.4.5 (2022-03-31)
diff --git a/devenv/dev-dashboards/all-panels.json b/devenv/dev-dashboards/all-panels.json
index 6e4cf7cd21d..a76161c64f2 100644
--- a/devenv/dev-dashboards/all-panels.json
+++ b/devenv/dev-dashboards/all-panels.json
@@ -374,17 +374,6 @@
"title": "Dashboard list",
"type": "dashlist"
},
- {
- "gridPos": {
- "h": 8,
- "w": 12,
- "x": 0,
- "y": 29
- },
- "id": 30,
- "title": "Panel list",
- "type": "pluginlist"
- },
{
"gridPos": {
"h": 8,
diff --git a/docs/sources/dashboards/dashboard-ui/dashboard-row.md b/docs/sources/dashboards/dashboard-ui/dashboard-row.md
index b00e1f1c80c..52e637ed689 100644
--- a/docs/sources/dashboards/dashboard-ui/dashboard-row.md
+++ b/docs/sources/dashboards/dashboard-ui/dashboard-row.md
@@ -4,7 +4,7 @@ aliases = ["/docs/grafana/latest/features/dashboard/dashboards/"]
weight = 80
+++
-# Dshboard rows
+# Dashboard rows
A dashboard row is a logical divider within a dashboard. It is used to group panels together.
diff --git a/docs/sources/datasources/elasticsearch.md b/docs/sources/datasources/elasticsearch.md
index 07a46386c94..24e0d7ef3df 100644
--- a/docs/sources/datasources/elasticsearch.md
+++ b/docs/sources/datasources/elasticsearch.md
@@ -143,23 +143,23 @@ types of template variables.
### Query variable
-The Elasticsearch data source supports two types of queries you can use in the _Query_ field of _Query_ variables. The query is written using a custom JSON string.
+The Elasticsearch data source supports two types of queries you can use in the _Query_ field of _Query_ variables. The query is written using a custom JSON string. The field should be mapped as a [keyword](https://www.elastic.co/guide/en/elasticsearch/reference/current/keyword.html#keyword) in the Elasticsearch index mapping. If it is [multi-field](https://www.elastic.co/guide/en/elasticsearch/reference/current/multi-fields.html) with both a `text` and `keyword` type, then use `"field":"fieldname.keyword"`(sometimes`fieldname.raw`) to specify the keyword field in your query.
-| Query | Description |
-| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `{"find": "fields", "type": "keyword"}` | Returns a list of field names with the index type `keyword`. |
-| `{"find": "terms", "field": "@hostname", "size": 1000}` | Returns a list of values for a field using term aggregation. Query will use current dashboard time range as time range for query. |
-| `{"find": "terms", "field": "@hostname", "query": ''}` | Returns a list of values for a field using term aggregation and a specified lucene query filter. Query will use current dashboard time range as time range for query. |
+| Query | Description |
+| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `{"find": "fields", "type": "keyword"}` | Returns a list of field names with the index type `keyword`. |
+| `{"find": "terms", "field": "hostname.keyword", "size": 1000}` | Returns a list of values for a keyword using term aggregation. Query will use current dashboard time range as time range query. |
+| `{"find": "terms", "field": "hostname", "query": ''}` | Returns a list of values for a keyword field using term aggregation and a specified lucene query filter. Query will use current dashboard time range as time range for query. |
There is a default size limit of 500 on terms queries. Set the size property in your query to set a custom limit.
You can use other variables inside the query. Example query definition for a variable named `$host`.
```
-{"find": "terms", "field": "@hostname", "query": "@source:$source"}
+{"find": "terms", "field": "hostname", "query": "source:$source"}
```
-In the above example, we use another variable named `$source` inside the query definition. Whenever you change, via the dropdown, the current value of the ` $source` variable, it will trigger an update of the `$host` variable so it now only contains hostnames filtered by in this case the
-`@source` document property.
+In the above example, we use another variable named `$source` inside the query definition. Whenever you change, via the dropdown, the current value of the `$source` variable, it will trigger an update of the `$host` variable so it now only contains hostnames filtered by in this case the
+`source` document property.
These queries by default return results in term order (which can then be sorted alphabetically or numerically as for any variable).
To produce a list of terms sorted by doc count (a top-N values list), add an `orderBy` property of "doc_count".
@@ -167,22 +167,22 @@ This automatically selects a descending sort; using "asc" with doc_count (a bott
To keep terms in the doc count order, set the variable's Sort dropdown to **Disabled**; you might alternatively still want to use e.g. **Alphabetical** to re-sort them.
```
-{"find": "terms", "field": "@hostname", "orderBy": "doc_count"}
+{"find": "terms", "field": "hostname", "orderBy": "doc_count"}
```
### Using variables in queries
There are two syntaxes:
-- `$` Example: @hostname:$hostname
-- `[[varname]]` Example: @hostname:[[hostname]]
+- `$` Example: hostname:$hostname
+- `[[varname]]` Example: hostname:[[hostname]]
Why two ways? The first syntax is easier to read and write but does not allow you to use a variable in the middle of a word. When the _Multi-value_ or _Include all value_
options are enabled, Grafana converts the labels from plain text to a lucene compatible condition.

-In the above example, we have a lucene query that filters documents based on the `@hostname` property using a variable named `$hostname`. It is also using
+In the above example, we have a lucene query that filters documents based on the `hostname` property using a variable named `$hostname`. It is also using
a variable in the _Terms_ group by field input box. This allows you to use a variable to quickly change how the data is grouped.
Example dashboard:
diff --git a/docs/sources/datasources/prometheus.md b/docs/sources/datasources/prometheus.md
index 8768966321e..0af9f91187c 100644
--- a/docs/sources/datasources/prometheus.md
+++ b/docs/sources/datasources/prometheus.md
@@ -16,23 +16,25 @@ Grafana includes built-in support for Prometheus. This topic explains options, v
To access Prometheus settings, hover your mouse over the **Configuration** (gear) icon, then click **Data Sources**, and then click the Prometheus data source.
-| Name | Description |
-| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `Name` | The data source name. This is how you refer to the data source in panels and queries. |
-| `Default` | Default data source that is pre-selected for new panels. |
-| `Url` | The URL of your Prometheus server, for example, `http://prometheus.example.org:9090`. |
-| `Access` | Server (default) = URL needs to be accessible from the Grafana backend/server, Browser = URL needs to be accessible from the browser. **Note**: Browser (direct) access is deprecated and will be removed in a future release. |
-| `Basic Auth` | Enable basic authentication to the Prometheus data source. |
-| `User` | User name for basic authentication. |
-| `Password` | Password for basic authentication. |
-| `Scrape interval` | Set this to the typical scrape and evaluation interval configured in Prometheus. Defaults to 15s. |
-| `HTTP method` | Use either POST or GET HTTP method to query your data source. POST is the recommended and pre-selected method as it allows bigger queries. Change this to GET if you have a Prometheus version older than 2.1 or if POST requests are restricted in your network. |
-| `Disable metrics lookup` | Checking this option will disable the metrics chooser and metric/label support in the query field's autocomplete. This helps if you have performance issues with bigger Prometheus instances. |
-| `Custom Query Parameters` | Add custom parameters to the Prometheus query URL. For example `timeout`, `partial_response`, `dedup`, or `max_source_resolution`. Multiple parameters should be concatenated together with an '&'. |
-| `Label name` | Add the name of the field in the label object. |
-| `URL` | If the link is external, then enter the full link URL. You can interpolate the value from the field with `${__value.raw }` macro. |
-| `URL Label` | (Optional) Set a custom display label for the link URL. The link label defaults to the full external URL or the name of datasource and is overridden by this setting. |
-| `Internal link` | Select if the link is internal or external. In the case of an internal link, a data source selector allows you to select the target data source. Supports tracing data sources only. |
+| Name | Description |
+| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `Name` | The data source name. This is how you refer to the data source in panels and queries. |
+| `Default` | Default data source that is pre-selected for new panels. |
+| `Url` | The URL of your Prometheus server, for example, `http://prometheus.example.org:9090`. |
+| `Access` | Server (default) = URL needs to be accessible from the Grafana backend/server, Browser = URL needs to be accessible from the browser. **Note**: Browser (direct) access is deprecated and will be removed in a future release. |
+| `Basic Auth` | Enable basic authentication to the Prometheus data source. |
+| `User` | User name for basic authentication. |
+| `Password` | Password for basic authentication. |
+| `Scrape interval` | Set this to the typical scrape and evaluation interval configured in Prometheus. Defaults to 15s. |
+| `HTTP method` | Use either POST or GET HTTP method to query your data source. POST is the recommended and pre-selected method as it allows bigger queries. Change this to GET if you have a Prometheus version older than 2.1 or if POST requests are restricted in your network. |
+| `Disable metrics lookup` | Checking this option will disable the metrics chooser and metric/label support in the query field's autocomplete. This helps if you have performance issues with bigger Prometheus instances. |
+| `Custom Query Parameters` | Add custom parameters to the Prometheus query URL. For example `timeout`, `partial_response`, `dedup`, or `max_source_resolution`. Multiple parameters should be concatenated together with an '&'. |
+| **Exemplars configuration** | |
+| `Internal link` | Enable this option is you have an internal link. When you enable this option, you will see a data source selector. Select the backend tracing data store for your exemplar data. |
+| `Data source` | You will see this option only if you enable `Internal link` option. Select the backend tracing data store for your exemplar data. |
+| `URL` | You will see this option only if the `Internal link` option is disabled. Enter the full URL of the external link. You can interpolate the value from the field with `${__value.raw }` macro. |
+| `URL Label` | (Optional) add a custom display label to override the value of the `Label name` field. |
+| `Label name` | Add a name for the exemplar traceID property. |
## Prometheus query editor
diff --git a/docs/sources/enterprise/license/license-restrictions.md b/docs/sources/enterprise/license/license-restrictions.md
index 2e4b068a450..080803aac4c 100644
--- a/docs/sources/enterprise/license/license-restrictions.md
+++ b/docs/sources/enterprise/license/license-restrictions.md
@@ -29,7 +29,7 @@ Your Grafana license includes a maximum number of _Viewer_ and _Editor/Admin_ ac
- An _active user_ is a user who has signed in to Grafana within the last 30 days. This is a rolling window that is updated daily.
- When you reach the number of maximum active viewers or editor/admins, only currently active users can sign in; new users and non-active users cannot sign in when you reach the limit.
-- Grafana applies sign-in restrictions separately for viewers and editor/admins. If your Grafana license reaches its limit of active viewers but not its limit of active editor/ddmins, new editors and admins can still sign in.
+- Grafana applies sign-in restrictions separately for viewers and editor/admins. If your Grafana license reaches its limit of active viewers but not its limit of active editor/admins, new editors and admins can still sign in.
- The number of dashboards that a user can view or edit, and the number of organizations that they can access does not affect the active user count. A user with editor permissions for many dashboards across many different organizations counts as one editor.
- A license limit banner appears to administrators when Grafana reaches its active user limit; editors and viewers do not see the banner.
To change user roles to make better use of your licenses, refer to [Optimize your tiered license](#optimize-your-tiered-license).
@@ -123,7 +123,7 @@ After you apply the token, Grafana Enterprise resets your license and updates th
> If you are running Grafana Enterprise 8.2 or earlier, the license grants you the total number of licensed users _for each user type_.
-For example, if your current license includes 60 viewers and 40 dditor/admins, the new license includes 100 viewers and 100 editor/admins. Grafana Enterprise 8.3 removes the distinction between viewers and editor/admins as shown on the **Utilization** panel.
+For example, if your current license includes 60 viewers and 40 editor/admins, the new license includes 100 viewers and 100 editor/admins. Grafana Enterprise 8.3 removes the distinction between viewers and editor/admins as shown on the **Utilization** panel.
Before you upgrade to Grafana 8.3, ensure that the total number of active users in Grafana does not exceed the number of users in your combined license. If it does, then new users cannot sign in to Grafana 8.3 until the active user count returns below the licensed limit.
diff --git a/docs/sources/release-notes/_index.md b/docs/sources/release-notes/_index.md
index 7eb143ae827..0c8f2f85b44 100644
--- a/docs/sources/release-notes/_index.md
+++ b/docs/sources/release-notes/_index.md
@@ -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.5.0-beta1]({{< relref "release-notes-8-5-0-beta1" >}})
- [Release notes for 8.4.5]({{< relref "release-notes-8-4-5" >}})
- [Release notes for 8.4.4]({{< relref "release-notes-8-4-4" >}})
- [Release notes for 8.4.3]({{< relref "release-notes-8-4-3" >}})
diff --git a/docs/sources/release-notes/release-notes-8-5-0-beta1.md b/docs/sources/release-notes/release-notes-8-5-0-beta1.md
new file mode 100644
index 00000000000..24d703a0725
--- /dev/null
+++ b/docs/sources/release-notes/release-notes-8-5-0-beta1.md
@@ -0,0 +1,30 @@
++++
+title = "Release notes for Grafana 8.5.0-beta1"
+hide_menu = true
++++
+
+
+
+# Release notes for Grafana 8.5.0-beta1
+
+### Features and enhancements
+
+- Add config option to enable/disable reporting. (Enterprise)
+- **Alerting:** Accurately set value for prom-compatible APIs. [#47216](https://github.com/grafana/grafana/pull/47216), [@gotjosh](https://github.com/gotjosh)
+- **Alerting:** Provisioning API - Notification Policies. [#46755](https://github.com/grafana/grafana/pull/46755), [@alexweav](https://github.com/alexweav)
+- **Analytics:** Enable grafana and plugin update checks to be operated independently. [#46352](https://github.com/grafana/grafana/pull/46352), [@wbrowne](https://github.com/wbrowne)
+- **Azure Monitor:** Add support for multiple template variables in resource picker. [#46215](https://github.com/grafana/grafana/pull/46215), [@sarahzinger](https://github.com/sarahzinger)
+- **Caching:** Add separate TTL for resources cache. (Enterprise)
+- **Caching:** add support for TLS configuration for Redis Cluster. (Enterprise)
+- **NewsPanel:** Remove Use Proxy option and update documentation with recommendations. [#47189](https://github.com/grafana/grafana/pull/47189), [@joshhunt](https://github.com/joshhunt)
+- **OAuth:** Sync GitHub OAuth user name to Grafana if it's set. [#45438](https://github.com/grafana/grafana/pull/45438), [@pallxk](https://github.com/pallxk)
+
+### Bug fixes
+
+- **Plugins:** Fix Default Nav URL for dashboard includes. [#47143](https://github.com/grafana/grafana/pull/47143), [@wbrowne](https://github.com/wbrowne)
+
+### Breaking changes
+
+When user is using Github OAuth, GitHub login is showed as both Grafana login and name. Now the GitHub name is showed as Grafana name, and GitHub login is showed as Grafana Login. Issue [#45438](https://github.com/grafana/grafana/issues/45438)
+
+The meaning of the default data source has now changed from being a persisted property in a panel. Before when you selected the default data source for a panel and later changed the default data source to another data source it would change all panels who were configured to use the default data source. From now on the default data source is just the default for new panels and changing the default will not impact any currently saved dashboards. Issue [#45132](https://github.com/grafana/grafana/issues/45132)
diff --git a/go.mod b/go.mod
index 0c8971576ff..d0646277b24 100644
--- a/go.mod
+++ b/go.mod
@@ -108,8 +108,8 @@ require (
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
- golang.org/x/tools v0.1.5
- gonum.org/v1/gonum v0.9.3
+ golang.org/x/tools v0.1.9
+ gonum.org/v1/gonum v0.11.0
google.golang.org/api v0.60.0
google.golang.org/grpc v1.42.0
google.golang.org/protobuf v1.27.1
diff --git a/go.sum b/go.sum
index 9d96f8e6df1..707fb9c42b0 100644
--- a/go.sum
+++ b/go.sum
@@ -76,6 +76,7 @@ cuelang.org/go v0.4.0/go.mod h1:tz/edkPi+T37AZcb5GlPY+WJkL6KiDlDVupKwL3vvjs=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
+git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc=
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
github.com/Azure/azure-amqp-common-go/v3 v3.0.0/go.mod h1:SY08giD/XbhTz07tJdpw1SoxQXHPN30+DI3Z04SYqyg=
github.com/Azure/azure-amqp-common-go/v3 v3.2.1/go.mod h1:O6X1iYHP7s2x7NjUKsXVhkwWrQhxrd+d8/3rRadj4CI=
@@ -286,7 +287,10 @@ github.com/aerospike/aerospike-client-go v1.27.0/go.mod h1:zj8LBEnWBDOVEIJt8LvaR
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
+github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
+github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
+github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -440,6 +444,7 @@ github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+Wji
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
github.com/bonitoo-io/go-sql-bigquery v0.3.4-1.4.0/go.mod h1:J4Y6YJm0qTWB9aFziB7cPeSyc6dOZFyJdteSeybVpXQ=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
+github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b h1:L/QXpzIa3pOvUGt1D1lA5KjYhPBAN/3iWdP7xeFS9F0=
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
@@ -892,6 +897,7 @@ github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm
github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g=
github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks=
github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
+github.com/go-fonts/liberation v0.2.0/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -905,6 +911,7 @@ github.com/go-kit/kit v0.11.0/go.mod h1:73/6Ixaufkvb5Osvkls8C79vuQ49Ba1rUEUYNSf+
github.com/go-kit/log v0.1.0 h1:DGJh0Sm43HbOeYDNnVZFl8BvcYVvjD5bqYJvp0REbwQ=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U=
+github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk=
github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
github.com/go-ldap/ldap/v3 v3.1.3/go.mod h1:3rbOH3jRS2u6jg2rJnKAMLE/xQyCKIveG2Sa/Cohzb8=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
@@ -1044,6 +1051,8 @@ github.com/go-openapi/validate v0.19.15/go.mod h1:tbn/fdOwYHgrhPBzidZfJC2MIVvs9G
github.com/go-openapi/validate v0.20.1/go.mod h1:b60iJT+xNNLfaQJUqLI7946tYiFEOuE9E4k54HpKcJ0=
github.com/go-openapi/validate v0.20.2 h1:AhqDegYV3J3iQkMPJSXkvzymHKMTw0BST3RK3hTT4ts=
github.com/go-openapi/validate v0.20.2/go.mod h1:e7OJoKNgd0twXZwIn0A43tHbvIcr/rZIVCbJBpTUoY0=
+github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M=
+github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
@@ -2068,6 +2077,7 @@ github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG
github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
+github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pierrec/lz4 v2.5.2+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
@@ -2264,6 +2274,7 @@ github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
+github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
@@ -2546,6 +2557,7 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/gopher-lua v0.0.0-20180630135845-46796da1b0b4/go.mod h1:aEV29XrmTYFr3CiRxZeGHpkvbwq+prZduBqMaascyCU=
github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da/go.mod h1:E1AXubJBdNmFERAOucpDIxNzeGfLzg0mYh+UfMWdChA=
github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
@@ -2754,6 +2766,10 @@ golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+o
golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210216034530-4410531fe030/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.0.0-20210607152325-775e3b0c77b9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
+golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
+golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
+golang.org/x/image v0.0.0-20220302094943-723b81ca9867/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -2782,6 +2798,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
+golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -3206,8 +3223,9 @@ golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.5 h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.9 h1:j9KsMiaP1c3B0OTQGth0/k+miLGTgLsAFUCrF2vLcF8=
+golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -3220,13 +3238,14 @@ gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJ
gonum.org/v1/gonum v0.6.0/go.mod h1:9mxDZsDKxgMAuccQkewq682L+0eCu4dCN2yonUJTCLU=
gonum.org/v1/gonum v0.6.2/go.mod h1:9mxDZsDKxgMAuccQkewq682L+0eCu4dCN2yonUJTCLU=
gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
-gonum.org/v1/gonum v0.9.3 h1:DnoIG+QAMaF5NvxnGe/oKsgKcAc6PcUyl8q0VetfQ8s=
gonum.org/v1/gonum v0.9.3/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0=
+gonum.org/v1/gonum v0.11.0 h1:f1IJhK4Km5tBJmaiJXtk/PkL4cdVX6J+tGiM187uT5E=
+gonum.org/v1/gonum v0.11.0/go.mod h1:fSG4YDCxxUZQJ7rKsQrj0gMOg00Il0Z96/qMA4bVQhA=
gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
-gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc=
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
gonum.org/v1/plot v0.9.0/go.mod h1:3Pcqqmp6RHvJI72kgb8fThyUnav364FOsdDo2aGW5lY=
+gonum.org/v1/plot v0.10.1/go.mod h1:VZW5OlhkL1mysU9vaqNHnsy86inf6Ot+jB3r+BczCEo=
google.golang.org/api v0.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/api v0.3.2/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
@@ -3520,6 +3539,7 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
honnef.co/go/tools v0.2.0/go.mod h1:lPVVZ2BS5TfnjLyizF7o7hv7j9/L+8cZY2hLyjP9cGY=
howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0=
inet.af/netaddr v0.0.0-20210707202901-70468d781e6c/go.mod h1:z0nx+Dh+7N7CC8V5ayHtHGpZpxLQZZxkIaaz6HN65Ls=
diff --git a/latest.json b/latest.json
index 68649300d2e..f1c9e890e90 100644
--- a/latest.json
+++ b/latest.json
@@ -1,4 +1,4 @@
{
"stable": "8.4.5",
- "testing": "8.4.5"
+ "testing": "8.5.0-beta1"
}
diff --git a/lerna.json b/lerna.json
index a4f8c6aa934..9a6406e5399 100644
--- a/lerna.json
+++ b/lerna.json
@@ -2,5 +2,5 @@
"npmClient": "yarn",
"useWorkspaces": true,
"packages": ["packages/*"],
- "version": "8.5.0-pre"
+ "version": "9.0.0-pre"
}
diff --git a/package.json b/package.json
index 74ddc4c7a4d..37f76582ca8 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"license": "AGPL-3.0-only",
"private": true,
"name": "grafana",
- "version": "8.5.0-pre",
+ "version": "9.0.0-pre",
"repository": "github:grafana/grafana",
"scripts": {
"api-tests": "jest --notify --watch --config=devenv/e2e-api-tests/jest.js",
@@ -109,7 +109,7 @@
"@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1",
"@swc/core": "1.2.160",
"@swc/helpers": "0.3.8",
- "@testing-library/dom": "8.12.0",
+ "@testing-library/dom": "8.13.0",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.4",
"@testing-library/react-hooks": "7.0.2",
@@ -202,7 +202,7 @@
"jest-canvas-mock": "2.3.1",
"jest-date-mock": "1.0.8",
"jest-fail-on-console": "2.3.0",
- "jest-junit": "13.0.0",
+ "jest-junit": "13.1.0",
"jest-matcher-utils": "27.5.1",
"jest-mock-console": "1.2.3",
"lerna": "^4.0.0",
@@ -237,7 +237,7 @@
"ts-node": "10.7.0",
"typescript": "4.4.4",
"wait-on": "6.0.1",
- "webpack": "5.70.0",
+ "webpack": "5.72.0",
"webpack-bundle-analyzer": "4.5.0",
"webpack-cli": "4.9.2",
"webpack-dev-server": "4.7.4",
@@ -276,7 +276,7 @@
"@react-stately/collections": "3.3.7",
"@react-stately/menu": "3.2.6",
"@react-stately/tree": "3.2.3",
- "@reduxjs/toolkit": "1.7.2",
+ "@reduxjs/toolkit": "1.8.0",
"@sentry/browser": "6.19.1",
"@sentry/types": "6.19.1",
"@sentry/utils": "6.19.1",
@@ -396,7 +396,7 @@
"@rushstack/node-core-library": "3.45.1",
"@rushstack/rig-package": "0.3.8",
"@rushstack/ts-command-line": "4.10.7",
- "@storybook/react/webpack": "5.70.0",
+ "@storybook/react/webpack": "5.72.0",
"node-fetch": "2.6.7"
},
"workspaces": {
diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json
index bda9b762e7e..2f761e9a3f3 100644
--- a/packages/grafana-data/package.json
+++ b/packages/grafana-data/package.json
@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/data",
- "version": "8.5.0-pre",
+ "version": "9.0.0-pre",
"description": "Grafana Data Library",
"keywords": [
"typescript"
@@ -22,7 +22,7 @@
},
"dependencies": {
"@braintree/sanitize-url": "6.0.0",
- "@grafana/schema": "8.5.0-pre",
+ "@grafana/schema": "9.0.0-pre",
"@types/d3-interpolate": "^1.4.0",
"d3-interpolate": "1.4.0",
"date-fns": "2.28.0",
@@ -47,7 +47,7 @@
"@rollup/plugin-json": "4.1.0",
"@rollup/plugin-node-resolve": "13.1.3",
"@swc/helpers": "0.3.8",
- "@testing-library/dom": "8.12.0",
+ "@testing-library/dom": "8.13.0",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.4",
"@testing-library/react-hooks": "7.0.2",
diff --git a/packages/grafana-data/src/transformations/transformers/histogram.ts b/packages/grafana-data/src/transformations/transformers/histogram.ts
index eef3698c90e..a48f00c1861 100644
--- a/packages/grafana-data/src/transformations/transformers/histogram.ts
+++ b/packages/grafana-data/src/transformations/transformers/histogram.ts
@@ -159,7 +159,9 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform
for (const frame of frames) {
for (const field of frame.fields) {
if (field.type === FieldType.number) {
- allValues = allValues.concat(field.values.toArray());
+ allValues = allValues.concat(
+ field.values.toArray().map((val: number) => Number(val.toFixed(field.config.decimals ?? 0)))
+ );
}
}
}
@@ -217,7 +219,7 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform
unit: undefined,
},
});
- if (!config && field.config.unit) {
+ if (!config && Object.keys(field.config).length) {
config = field.config;
}
}
diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts
index 94276a9114d..69717854e2b 100644
--- a/packages/grafana-data/src/types/datasource.ts
+++ b/packages/grafana-data/src/types/datasource.ts
@@ -481,7 +481,6 @@ export interface DataQueryRequest {
intervalMs: number;
maxDataPoints?: number;
range: TimeRange;
- reverse?: boolean;
scopedVars: ScopedVars;
targets: TQuery[];
timezone: string;
diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index dcd04ac309e..23e997e4dd6 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -41,6 +41,7 @@ export interface FeatureToggles {
showFeatureFlagsInUI?: boolean;
disable_http_request_histogram?: boolean;
validatedQueries?: boolean;
+ publicDashboards?: boolean;
lokiLive?: boolean;
swaggerUi?: boolean;
featureHighlights?: boolean;
@@ -52,4 +53,5 @@ export interface FeatureToggles {
alertProvisioning?: boolean;
storageLocalUpload?: boolean;
azureMonitorResourcePickerForMetrics?: boolean;
+ explore2Dashboard?: boolean;
}
diff --git a/packages/grafana-data/src/utils/location.test.ts b/packages/grafana-data/src/utils/location.test.ts
index 174a505601d..b56660bbd01 100644
--- a/packages/grafana-data/src/utils/location.test.ts
+++ b/packages/grafana-data/src/utils/location.test.ts
@@ -3,7 +3,7 @@ import { locationUtil } from './location';
describe('locationUtil', () => {
const { location } = window;
- beforeAll(() => {
+ beforeEach(() => {
// @ts-ignore
delete window.location;
@@ -21,63 +21,97 @@ describe('locationUtil', () => {
};
});
- afterAll(() => {
+ afterEach(() => {
window.location = location;
});
- describe('strip base when appSubUrl configured', () => {
- beforeEach(() => {
- locationUtil.initialize({
- config: { appSubUrl: '/subUrl' } as any,
- getVariablesUrlParams: (() => {}) as any,
- getTimeRangeForUrl: (() => {}) as any,
+ describe('stripBaseFromUrl', () => {
+ describe('when appSubUrl configured', () => {
+ beforeEach(() => {
+ locationUtil.initialize({
+ config: { appSubUrl: '/subUrl' } as any,
+ getVariablesUrlParams: (() => {}) as any,
+ getTimeRangeForUrl: (() => {}) as any,
+ });
});
- });
- test('relative url', () => {
- const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/thisShouldRemain/');
- expect(urlWithoutMaster).toBe('/thisShouldRemain/');
- });
- test('relative url with multiple subUrl in path', () => {
- const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/thisShouldRemain/subUrl/');
- expect(urlWithoutMaster).toBe('/thisShouldRemain/subUrl/');
- });
- test('relative url with subdirectory subUrl', () => {
- const urlWithoutMaster = locationUtil.stripBaseFromUrl('/thisShouldRemain/subUrl/');
- expect(urlWithoutMaster).toBe('/thisShouldRemain/subUrl/');
- });
- test('absolute url', () => {
- const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/subUrl/thisShouldRemain/');
- expect(urlWithoutMaster).toBe('/thisShouldRemain/');
- });
- test('absolute url with multiple subUrl in path', () => {
- const urlWithoutMaster = locationUtil.stripBaseFromUrl(
- 'http://www.domain.com:9877/subUrl/thisShouldRemain/subUrl/'
- );
- expect(urlWithoutMaster).toBe('/thisShouldRemain/subUrl/');
- });
- test('absolute url with subdirectory subUrl', () => {
- const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/thisShouldRemain/subUrl/');
- expect(urlWithoutMaster).toBe('http://www.domain.com:9877/thisShouldRemain/subUrl/');
- });
- });
-
- describe('strip base when appSubUrl not configured', () => {
- beforeEach(() => {
- locationUtil.initialize({
- config: {} as any,
- getVariablesUrlParams: (() => {}) as any,
- getTimeRangeForUrl: (() => {}) as any,
+ test('relative url', () => {
+ const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/thisShouldRemain/');
+ expect(urlWithoutMaster).toBe('/thisShouldRemain/');
+ });
+ test('relative url with multiple subUrl in path', () => {
+ const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/thisShouldRemain/subUrl/');
+ expect(urlWithoutMaster).toBe('/thisShouldRemain/subUrl/');
+ });
+ test('relative url with subdirectory subUrl', () => {
+ const urlWithoutMaster = locationUtil.stripBaseFromUrl('/thisShouldRemain/subUrl/');
+ expect(urlWithoutMaster).toBe('/thisShouldRemain/subUrl/');
+ });
+ test('absolute url', () => {
+ const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/subUrl/thisShouldRemain/');
+ expect(urlWithoutMaster).toBe('/thisShouldRemain/');
+ });
+ test('absolute url with multiple subUrl in path', () => {
+ const urlWithoutMaster = locationUtil.stripBaseFromUrl(
+ 'http://www.domain.com:9877/subUrl/thisShouldRemain/subUrl/'
+ );
+ expect(urlWithoutMaster).toBe('/thisShouldRemain/subUrl/');
+ });
+ test('absolute url with subdirectory subUrl', () => {
+ const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/thisShouldRemain/subUrl/');
+ expect(urlWithoutMaster).toBe('http://www.domain.com:9877/thisShouldRemain/subUrl/');
});
});
- test('relative url', () => {
- const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/grafana/');
- expect(urlWithoutMaster).toBe('/subUrl/grafana/');
+ describe('when appSubUrl not configured', () => {
+ beforeEach(() => {
+ locationUtil.initialize({
+ config: {} as any,
+ getVariablesUrlParams: (() => {}) as any,
+ getTimeRangeForUrl: (() => {}) as any,
+ });
+ });
+
+ test('relative url', () => {
+ const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/grafana/');
+ expect(urlWithoutMaster).toBe('/subUrl/grafana/');
+ });
+
+ test('absolute url', () => {
+ const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/subUrl/grafana/');
+ expect(urlWithoutMaster).toBe('/subUrl/grafana/');
+ });
});
- test('absolute url', () => {
- const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/subUrl/grafana/');
- expect(urlWithoutMaster).toBe('/subUrl/grafana/');
+ describe('when origin does not have a port in it', () => {
+ beforeEach(() => {
+ window.location = {
+ ...location,
+ hash: '#hash',
+ host: 'www.domain.com',
+ hostname: 'www.domain.com',
+ href: 'http://www.domain.com/path/b?search=a&b=c&d#hash',
+ origin: 'http://www.domain.com',
+ pathname: '/path/b',
+ port: '',
+ protocol: 'http:',
+ search: '?search=a&b=c&d',
+ };
+ });
+
+ test('relative url', () => {
+ const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/grafana/');
+ expect(urlWithoutMaster).toBe('/subUrl/grafana/');
+ });
+
+ test('URL with same host, different port', () => {
+ const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.domain.com:9877/subUrl/grafana/');
+ expect(urlWithoutMaster).toBe('http://www.domain.com:9877/subUrl/grafana/');
+ });
+
+ test('URL of a completely different origin', () => {
+ const urlWithoutMaster = locationUtil.stripBaseFromUrl('http://www.another-domain.com/subUrl/grafana/');
+ expect(urlWithoutMaster).toBe('http://www.another-domain.com/subUrl/grafana/');
+ });
});
});
diff --git a/packages/grafana-data/src/utils/location.ts b/packages/grafana-data/src/utils/location.ts
index 83f481067bb..7df8796b41b 100644
--- a/packages/grafana-data/src/utils/location.ts
+++ b/packages/grafana-data/src/utils/location.ts
@@ -7,22 +7,43 @@ let grafanaConfig: GrafanaConfig = { appSubUrl: '' } as any;
let getTimeRangeUrlParams: () => RawTimeRange;
let getVariablesUrlParams: (scopedVars?: ScopedVars) => UrlQueryMap;
+const maybeParseUrl = (input: string): URL | undefined => {
+ try {
+ return new URL(input);
+ } catch {
+ return undefined;
+ }
+};
+
/**
*
* @param url
* @internal
*/
-const stripBaseFromUrl = (url: string): string => {
+const stripBaseFromUrl = (urlOrPath: string): string => {
+ // Will only return a URL object if the input is actually a valid URL
+ const parsedUrl = maybeParseUrl(urlOrPath);
+ if (parsedUrl) {
+ // If the input is a URL, and for a different origin that we're on, just bail
+ // and return it. There's no need to strip anything from it
+ if (parsedUrl.origin !== window.location.origin) {
+ return urlOrPath;
+ }
+ }
+
const appSubUrl = grafanaConfig.appSubUrl ?? '';
const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
- const isAbsoluteUrl = url.startsWith('http');
+ const isAbsoluteUrl = urlOrPath.startsWith('http');
+
let segmentToStrip = appSubUrl;
- if (!url.startsWith('/') || isAbsoluteUrl) {
+ if (!urlOrPath.startsWith('/') || isAbsoluteUrl) {
segmentToStrip = `${window.location.origin}${appSubUrl}`;
}
- return url.length > 0 && url.indexOf(segmentToStrip) === 0 ? url.slice(segmentToStrip.length - stripExtraChars) : url;
+ return urlOrPath.length > 0 && urlOrPath.indexOf(segmentToStrip) === 0
+ ? urlOrPath.slice(segmentToStrip.length - stripExtraChars)
+ : urlOrPath;
};
/**
diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json
index 571ea8dd91d..776d09e5500 100644
--- a/packages/grafana-e2e-selectors/package.json
+++ b/packages/grafana-e2e-selectors/package.json
@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/e2e-selectors",
- "version": "8.5.0-pre",
+ "version": "9.0.0-pre",
"description": "Grafana End-to-End Test Selectors Library",
"keywords": [
"cli",
diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json
index 10aeec505b3..6fb70e16064 100644
--- a/packages/grafana-e2e/package.json
+++ b/packages/grafana-e2e/package.json
@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/e2e",
- "version": "8.5.0-pre",
+ "version": "9.0.0-pre",
"description": "Grafana End-to-End Test Library",
"keywords": [
"cli",
@@ -41,14 +41,14 @@
"rollup-plugin-copy": "3.4.0",
"rollup-plugin-sourcemaps": "0.6.3",
"rollup-plugin-terser": "7.0.2",
- "webpack": "5.70.0"
+ "webpack": "5.72.0"
},
"types": "src/index.ts",
"dependencies": {
"@babel/core": "7.17.8",
"@babel/preset-env": "7.16.11",
"@cypress/webpack-preprocessor": "5.11.1",
- "@grafana/e2e-selectors": "8.5.0-pre",
+ "@grafana/e2e-selectors": "9.0.0-pre",
"@grafana/tsconfig": "^1.2.0-rc1",
"@mochajs/json-file-reporter": "^1.2.0",
"babel-loader": "8.2.4",
diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json
index 26039a58f38..5f22ae36d8c 100644
--- a/packages/grafana-runtime/package.json
+++ b/packages/grafana-runtime/package.json
@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/runtime",
- "version": "8.5.0-pre",
+ "version": "9.0.0-pre",
"description": "Grafana Runtime Library",
"keywords": [
"grafana",
@@ -22,9 +22,9 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@grafana/data": "8.5.0-pre",
- "@grafana/e2e-selectors": "8.5.0-pre",
- "@grafana/ui": "8.5.0-pre",
+ "@grafana/data": "9.0.0-pre",
+ "@grafana/e2e-selectors": "9.0.0-pre",
+ "@grafana/ui": "9.0.0-pre",
"@sentry/browser": "6.19.1",
"history": "4.10.1",
"lodash": "4.17.21",
@@ -38,7 +38,7 @@
"@grafana/tsconfig": "^1.2.0-rc1",
"@rollup/plugin-commonjs": "21.0.2",
"@rollup/plugin-node-resolve": "13.1.3",
- "@testing-library/dom": "8.12.0",
+ "@testing-library/dom": "8.13.0",
"@testing-library/react": "12.1.4",
"@testing-library/user-event": "^13.5.0",
"@types/angular": "1.8.4",
diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts
index 6a0e015626b..f2599a42894 100644
--- a/packages/grafana-runtime/src/config.ts
+++ b/packages/grafana-runtime/src/config.ts
@@ -119,7 +119,6 @@ export class GrafanaBootConfig implements GrafanaConfig {
this.theme2 = createTheme({ colors: { mode } });
this.theme = this.theme2.v1;
this.bootData = options.bootData;
- this.buildInfo = options.buildInfo;
const defaults = {
datasources: {},
@@ -131,7 +130,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
appUrl: '',
appSubUrl: '',
buildInfo: {
- version: 'v1.0',
+ version: '1.0',
commit: '1',
env: 'production',
},
@@ -142,6 +141,8 @@ export class GrafanaBootConfig implements GrafanaConfig {
merge(this, defaults, options);
+ this.buildInfo = options.buildInfo || defaults.buildInfo;
+
if (this.dateFormats) {
systemDateFormats.update(this.dateFormats);
}
diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json
index 30654d6f4fa..16db7c725b7 100644
--- a/packages/grafana-schema/package.json
+++ b/packages/grafana-schema/package.json
@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/schema",
- "version": "8.5.0-pre",
+ "version": "9.0.0-pre",
"description": "Grafana Schema Library",
"keywords": [
"typescript"
diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json
index d5c458cca80..467a23cf417 100644
--- a/packages/grafana-toolkit/package.json
+++ b/packages/grafana-toolkit/package.json
@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/toolkit",
- "version": "8.5.0-pre",
+ "version": "9.0.0-pre",
"description": "Grafana Toolkit",
"keywords": [
"grafana",
@@ -28,10 +28,10 @@
"dependencies": {
"@babel/core": "7.13.14",
"@babel/preset-env": "7.13.12",
- "@grafana/data": "8.5.0-pre",
+ "@grafana/data": "9.0.0-pre",
"@grafana/eslint-config": "2.5.2",
"@grafana/tsconfig": "^1.2.0-rc1",
- "@grafana/ui": "8.5.0-pre",
+ "@grafana/ui": "9.0.0-pre",
"@jest/core": "26.6.3",
"@rushstack/eslint-patch": "1.0.6",
"@types/command-exists": "^1.2.0",
diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json
index b10a5ba1d76..10130b923a7 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/ui",
- "version": "8.5.0-pre",
+ "version": "9.0.0-pre",
"description": "Grafana Components Library",
"keywords": [
"grafana",
@@ -33,9 +33,9 @@
"@emotion/css": "11.7.1",
"@emotion/react": "11.8.2",
"@grafana/aws-sdk": "0.0.35",
- "@grafana/data": "8.5.0-pre",
- "@grafana/e2e-selectors": "8.5.0-pre",
- "@grafana/schema": "8.5.0-pre",
+ "@grafana/data": "9.0.0-pre",
+ "@grafana/e2e-selectors": "9.0.0-pre",
+ "@grafana/schema": "9.0.0-pre",
"@grafana/slate-react": "0.22.10-grafana",
"@monaco-editor/react": "4.3.1",
"@popperjs/core": "2.11.4",
@@ -115,7 +115,7 @@
"@storybook/react": "6.4.15",
"@storybook/theming": "6.4.15",
"@swc/helpers": "0.3.8",
- "@testing-library/dom": "8.12.0",
+ "@testing-library/dom": "8.13.0",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.4",
"@testing-library/react-hooks": "7.0.2",
@@ -175,7 +175,7 @@
"terser-webpack-plugin": "5.3.1",
"ts-loader": "8.0.11",
"typescript": "4.4.4",
- "webpack": "5.70.0",
+ "webpack": "5.72.0",
"webpack-filter-warnings-plugin": "1.2.1"
},
"types": "src/index.ts"
diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx
index 95013d5f14d..7b1ee1b665b 100644
--- a/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx
+++ b/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx
@@ -71,6 +71,7 @@ export const Basic: Story = ({
name: '',
values: new ArrayVector([1, 2, 3, 4, 3]),
type: FieldType.number,
+ state: { range: { min: 1, max: 4, delta: 3 } },
config: {},
},
};
diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx
index 24fe7a67f77..c58c7b8c3a1 100644
--- a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx
+++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx
@@ -60,7 +60,7 @@ export const CollapsableSection: FC = ({
{loading ? (
) : (
-
+
)}
diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx
index 90f7aa89935..dcd271f5d44 100755
--- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx
+++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx
@@ -43,6 +43,7 @@ const defaultConfig: GraphFieldConfig = {
axisPlacement: AxisPlacement.Hidden,
};
+/** @internal */
export class Sparkline extends PureComponent
{
constructor(props: SparklineProps) {
super(props);
diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts
index 3deb48b17f1..3d04933efe2 100644
--- a/packages/grafana-ui/src/components/index.ts
+++ b/packages/grafana-ui/src/components/index.ts
@@ -84,6 +84,7 @@ export {
BigValueJustifyMode,
BigValueTextMode,
} from './BigValue/BigValue';
+export { Sparkline } from './Sparkline/Sparkline';
export { Gauge } from './Gauge/Gauge';
export { Graph } from './Graph/Graph';
diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json
index 992888ce8ad..b69f93aad83 100644
--- a/packages/jaeger-ui-components/package.json
+++ b/packages/jaeger-ui-components/package.json
@@ -1,6 +1,6 @@
{
"name": "@jaegertracing/jaeger-ui-components",
- "version": "8.5.0-pre",
+ "version": "9.0.0-pre",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -26,8 +26,8 @@
},
"dependencies": {
"@emotion/css": "11.7.1",
- "@grafana/data": "8.5.0-pre",
- "@grafana/ui": "8.5.0-pre",
+ "@grafana/data": "9.0.0-pre",
+ "@grafana/ui": "9.0.0-pre",
"chance": "^1.0.10",
"classnames": "^2.2.5",
"combokeys": "^3.0.0",
diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go
index b2d331eb213..0ba11679d3c 100644
--- a/pkg/api/admin_users_test.go
+++ b/pkg/api/admin_users_test.go
@@ -1,7 +1,6 @@
package api
import (
- "context"
"fmt"
"testing"
@@ -49,11 +48,6 @@ func TestAdminAPIEndpoint(t *testing.T) {
mock := mockstore.NewSQLStoreMock()
adminLogoutUserScenario(t, "Should not be allowed when calling POST on",
"/api/admin/users/1/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) {
- bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error {
- cmd.Result = &models.User{Id: testUserID}
- return nil
- })
-
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
assert.Equal(t, 400, sc.resp.Code)
}, mock)
@@ -182,8 +176,6 @@ func TestAdminAPIEndpoint(t *testing.T) {
}
adminCreateUserScenario(t, "Should create the user", "/api/admin/users", "/api/admin/users", createCmd, func(sc *scenarioContext) {
- bus.ClearBusHandlers()
-
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
assert.Equal(t, 200, sc.resp.Code)
@@ -202,8 +194,6 @@ func TestAdminAPIEndpoint(t *testing.T) {
}
adminCreateUserScenario(t, "Should create the user", "/api/admin/users", "/api/admin/users", createCmd, func(sc *scenarioContext) {
- bus.ClearBusHandlers()
-
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
assert.Equal(t, 200, sc.resp.Code)
@@ -222,8 +212,6 @@ func TestAdminAPIEndpoint(t *testing.T) {
}
adminCreateUserScenario(t, "Should create the user", "/api/admin/users", "/api/admin/users", createCmd, func(sc *scenarioContext) {
- bus.ClearBusHandlers()
-
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
assert.Equal(t, 400, sc.resp.Code)
@@ -241,8 +229,6 @@ func TestAdminAPIEndpoint(t *testing.T) {
}
adminCreateUserScenario(t, "Should return an error", "/api/admin/users", "/api/admin/users", createCmd, func(sc *scenarioContext) {
- bus.ClearBusHandlers()
-
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
assert.Equal(t, 412, sc.resp.Code)
@@ -256,8 +242,6 @@ func TestAdminAPIEndpoint(t *testing.T) {
func putAdminScenario(t *testing.T, desc string, url string, routePattern string, role models.RoleType,
cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
hs := &HTTPServer{
Cfg: setting.NewCfg(),
SQLStore: sqlStore,
@@ -284,8 +268,6 @@ func putAdminScenario(t *testing.T, desc string, url string, routePattern string
func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
hs := HTTPServer{
Bus: bus.GetBus(),
AuthTokenService: auth.NewFakeUserAuthTokenService(),
@@ -312,8 +294,6 @@ func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern
func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
@@ -343,8 +323,6 @@ func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, rou
func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
@@ -372,8 +350,6 @@ func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, route
func adminDisableUserScenario(t *testing.T, desc string, action string, url string, routePattern string, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
authInfoService := &logintest.AuthInfoServiceFake{}
@@ -410,8 +386,6 @@ func adminDeleteUserScenario(t *testing.T, desc string, url string, routePattern
SQLStore: mockstore.NewSQLStoreMock(),
}
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
sc := setupScenarioContext(t, url)
sc.sqlStore = hs.SQLStore
sc.authInfoService = &logintest.AuthInfoServiceFake{}
@@ -430,8 +404,6 @@ func adminDeleteUserScenario(t *testing.T, desc string, url string, routePattern
func adminCreateUserScenario(t *testing.T, desc string, url string, routePattern string, cmd dtos.AdminCreateUserForm, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
hs := HTTPServer{
Bus: bus.GetBus(),
Login: loginservice.LoginServiceMock{
diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go
index 4b58ace4bce..487d4ac9905 100644
--- a/pkg/api/alerting.go
+++ b/pkg/api/alerting.go
@@ -9,7 +9,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/guardian"
@@ -491,7 +490,7 @@ func (hs *HTTPServer) NotificationTest(c *models.ReqContext) response.Response {
SecureSettings: dto.SecureSettings,
}
- if err := bus.Dispatch(c.Req.Context(), cmd); err != nil {
+ if err := hs.AlertNotificationService.HandleNotificationTestCommand(c.Req.Context(), cmd); err != nil {
if errors.Is(err, models.ErrSmtpNotEnabled) {
return response.Error(412, err.Error(), err)
}
diff --git a/pkg/api/alerting_test.go b/pkg/api/alerting_test.go
index 1f09ca06a19..12deadcc9d5 100644
--- a/pkg/api/alerting_test.go
+++ b/pkg/api/alerting_test.go
@@ -9,7 +9,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/search"
@@ -124,18 +123,12 @@ func TestAlertingAPIEndpoint(t *testing.T) {
}
func callPauseAlert(sc *scenarioContext) {
- bus.AddHandler("test", func(ctx context.Context, cmd *models.PauseAlertCommand) error {
- return nil
- })
-
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
}
func postAlertScenario(t *testing.T, hs *HTTPServer, desc string, url string, routePattern string, role models.RoleType,
cmd dtos.PauseAlertCommand, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- defer bus.ClearBusHandlers()
-
sc := setupScenarioContext(t, url)
sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response {
c.Req.Body = mockRequestBody(cmd)
diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go
index 30b0af8b746..300a751454a 100644
--- a/pkg/api/annotations_test.go
+++ b/pkg/api/annotations_test.go
@@ -13,7 +13,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/annotations"
@@ -293,8 +292,6 @@ var fakeAnnoRepo *fakeAnnotationsRepo
func postAnnotationScenario(t *testing.T, desc string, url string, routePattern string, role models.RoleType,
cmd dtos.PostAnnotationsCmd, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
hs := setupSimpleHTTPServer(nil)
store := sqlstore.InitTestDB(t)
store.Cfg = hs.Cfg
@@ -324,8 +321,6 @@ func postAnnotationScenario(t *testing.T, desc string, url string, routePattern
func putAnnotationScenario(t *testing.T, desc string, url string, routePattern string, role models.RoleType,
cmd dtos.UpdateAnnotationsCmd, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
hs := setupSimpleHTTPServer(nil)
store := sqlstore.InitTestDB(t)
store.Cfg = hs.Cfg
@@ -354,8 +349,6 @@ func putAnnotationScenario(t *testing.T, desc string, url string, routePattern s
func patchAnnotationScenario(t *testing.T, desc string, url string, routePattern string, role models.RoleType, cmd dtos.PatchAnnotationsCmd, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- defer bus.ClearBusHandlers()
-
hs := setupSimpleHTTPServer(nil)
store := sqlstore.InitTestDB(t)
store.Cfg = hs.Cfg
@@ -385,8 +378,6 @@ func patchAnnotationScenario(t *testing.T, desc string, url string, routePattern
func deleteAnnotationsScenario(t *testing.T, desc string, url string, routePattern string, role models.RoleType,
cmd dtos.MassDeleteAnnotationsCmd, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- defer bus.ClearBusHandlers()
-
hs := setupSimpleHTTPServer(nil)
store := sqlstore.InitTestDB(t)
store.Cfg = hs.Cfg
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 16af1f809c6..12c7dbd4a8c 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/web"
)
var plog = log.New("api")
@@ -417,7 +418,14 @@ func (hs *HTTPServer) registerRoutes() {
alertsRoute.Get("/states-for-dashboard", routing.Wrap(hs.GetAlertStatesForDashboard))
})
- apiRoute.Get("/alert-notifiers", reqEditorRole, routing.Wrap(
+ var notifiersAuthHandler web.Handler
+ if hs.Cfg.UnifiedAlerting.IsEnabled() {
+ notifiersAuthHandler = reqSignedIn
+ } else {
+ notifiersAuthHandler = reqEditorRole
+ }
+
+ apiRoute.Get("/alert-notifiers", notifiersAuthHandler, routing.Wrap(
hs.GetAlertNotifiers(hs.Cfg.UnifiedAlerting.IsEnabled())),
)
diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go
index b256c1454d7..41ccf6ddd1c 100644
--- a/pkg/api/common_test.go
+++ b/pkg/api/common_test.go
@@ -53,8 +53,6 @@ func loggedInUserScenario(t *testing.T, desc string, url string, routePattern st
func loggedInUserScenarioWithRole(t *testing.T, desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
sc := setupScenarioContext(t, url)
sc.sqlStore = sqlStore
sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response {
@@ -82,8 +80,6 @@ func loggedInUserScenarioWithRole(t *testing.T, desc string, method string, url
func anonymousUserScenario(t *testing.T, desc string, method string, url string, routePattern string, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- defer bus.ClearBusHandlers()
-
sc := setupScenarioContext(t, url)
sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response {
sc.context = c
@@ -197,7 +193,9 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa
tracer, err := tracing.InitializeTracerForTest()
require.NoError(t, err)
authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginservice.LoginServiceMock{}, sqlStore)
- ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy)
+ loginService := &logintest.LoginServiceFake{}
+ authenticator := &logintest.AuthenticatorFake{}
+ ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, authenticator)
return ctxHdlr
}
diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go
index a6d1bcef985..d34d285c46d 100644
--- a/pkg/api/dashboard_snapshot_test.go
+++ b/pkg/api/dashboard_snapshot_test.go
@@ -1,7 +1,6 @@
package api
import (
- "context"
"fmt"
"net/http"
"net/http/httptest"
@@ -9,7 +8,6 @@ import (
"testing"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
@@ -52,12 +50,6 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
sqlmock.ExpectedDashboardAclInfoList = aclMockResp
sqlmock.ExpectedTeamsByUser = []*models.TeamDTO{}
- // we need it here for now for the guadian service to work
- bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardAclInfoListQuery) error {
- query.Result = aclMockResp
- return nil
- })
-
return mockSnapshotResult
}
diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go
index 54b77fbb520..24ec9479b2d 100644
--- a/pkg/api/dashboard_test.go
+++ b/pkg/api/dashboard_test.go
@@ -957,31 +957,17 @@ func (hs *HTTPServer) callGetDashboard(sc *scenarioContext) {
}
func (hs *HTTPServer) callGetDashboardVersion(sc *scenarioContext) {
- bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardVersionQuery) error {
- query.Result = &models.DashboardVersion{}
- return nil
- })
-
sc.handlerFunc = hs.GetDashboardVersion
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
}
func (hs *HTTPServer) callGetDashboardVersions(sc *scenarioContext) {
- bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardVersionsQuery) error {
- query.Result = []*models.DashboardVersionDTO{}
- return nil
- })
-
sc.handlerFunc = hs.GetDashboardVersions
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
}
func (hs *HTTPServer) callDeleteDashboardByUID(t *testing.T,
sc *scenarioContext, mockDashboard *dashboards.FakeDashboardService) {
- bus.AddHandler("test", func(ctx context.Context, cmd *models.DeleteDashboardCommand) error {
- return nil
- })
-
hs.dashboardService = mockDashboard
sc.handlerFunc = hs.DeleteDashboardByUID
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
@@ -1003,8 +989,6 @@ func callPostDashboardShouldReturnSuccess(sc *scenarioContext) {
func postDashboardScenario(t *testing.T, desc string, url string, routePattern string, cmd models.SaveDashboardCommand, dashboardService dashboards.DashboardService, folderService dashboards.FolderService, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
cfg := setting.NewCfg()
hs := HTTPServer{
Bus: bus.GetBus(),
@@ -1040,8 +1024,6 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s
func postDiffScenario(t *testing.T, desc string, url string, routePattern string, cmd dtos.CalculateDiffOptions, role models.RoleType, fn scenarioFunc, sqlmock sqlstore.Store) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- defer bus.ClearBusHandlers()
-
cfg := setting.NewCfg()
hs := HTTPServer{
Cfg: cfg,
@@ -1076,8 +1058,6 @@ func postDiffScenario(t *testing.T, desc string, url string, routePattern string
func restoreDashboardVersionScenario(t *testing.T, desc string, url string, routePattern string, mock *dashboards.FakeDashboardService, cmd dtos.RestoreDashboardVersionCommand, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- defer bus.ClearBusHandlers()
-
cfg := setting.NewCfg()
mockSQLStore := mockstore.NewSQLStoreMock()
hs := HTTPServer{
diff --git a/pkg/api/folder_permission_test.go b/pkg/api/folder_permission_test.go
index 18b45c069cb..9198f15e55b 100644
--- a/pkg/api/folder_permission_test.go
+++ b/pkg/api/folder_permission_test.go
@@ -15,7 +15,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
service "github.com/grafana/grafana/pkg/services/dashboards/manager"
@@ -355,8 +354,6 @@ func callUpdateFolderPermissions(t *testing.T, sc *scenarioContext) {
func updateFolderPermissionScenario(t *testing.T, ctx updatePermissionContext, hs *HTTPServer) {
t.Run(fmt.Sprintf("%s %s", ctx.desc, ctx.url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
sc := setupScenarioContext(t, ctx.url)
sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response {
diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go
index e61f232c1f3..aa437ea2cf5 100644
--- a/pkg/api/folder_test.go
+++ b/pkg/api/folder_test.go
@@ -138,8 +138,6 @@ func callCreateFolder(sc *scenarioContext) {
func createFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService,
cmd models.CreateFolderCommand, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
hs := HTTPServer{
Bus: bus.GetBus(),
Cfg: setting.NewCfg(),
@@ -170,8 +168,6 @@ func callUpdateFolder(sc *scenarioContext) {
func updateFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService,
cmd models.UpdateFolderCommand, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- defer bus.ClearBusHandlers()
-
hs := HTTPServer{
Cfg: setting.NewCfg(),
folderService: folderService,
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index 6d026fc5f26..fc87b642ca5 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -507,7 +507,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
m.Use(hs.pluginMetricsEndpoint)
m.Use(hs.ContextHandler.Middleware)
- m.Use(middleware.OrgRedirect(hs.Cfg))
+ m.Use(middleware.OrgRedirect(hs.Cfg, hs.SQLStore))
m.Use(acmiddleware.LoadPermissionsMiddleware(hs.AccessControl))
// needs to be after context handler
diff --git a/pkg/api/index.go b/pkg/api/index.go
index 20c21a3c7c6..d4e0bc8ce3d 100644
--- a/pkg/api/index.go
+++ b/pkg/api/index.go
@@ -307,7 +307,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
Id: "serviceaccounts",
Description: "Manage service accounts",
// TODO: change icon to "key-skeleton-alt" when it's available
- Icon: "key-skeleton-alt",
+ Icon: "keyhole-circle",
Url: hs.Cfg.AppSubURL + "/org/serviceaccounts",
})
}
diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go
index 56a6df3a5d1..5d40f15812a 100644
--- a/pkg/api/pluginproxy/ds_proxy_test.go
+++ b/pkg/api/pluginproxy/ds_proxy_test.go
@@ -466,19 +466,6 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
})
t.Run("When proxying a datasource that has OAuth token pass-through enabled", func(t *testing.T) {
- bus.AddHandler("test", func(ctx context.Context, query *models.GetAuthInfoQuery) error {
- query.Result = &models.UserAuth{
- Id: 1,
- UserId: 1,
- AuthModule: "generic_oauth",
- OAuthAccessToken: "testtoken",
- OAuthRefreshToken: "testrefreshtoken",
- OAuthTokenType: "Bearer",
- OAuthExpiry: time.Now().AddDate(0, 0, 1),
- }
- return nil
- })
-
ds := &models.DataSource{
Type: "custom-datasource",
Url: "http://host/root/",
diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go
index 8c8674fd6c4..3504d51e4cf 100644
--- a/pkg/api/plugins_test.go
+++ b/pkg/api/plugins_test.go
@@ -15,7 +15,6 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana-plugin-sdk-go/backend"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
@@ -218,8 +217,6 @@ func callGetPluginAsset(sc *scenarioContext) {
func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern string, pluginStore plugins.Store,
logger log.Logger, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- defer bus.ClearBusHandlers()
-
hs := HTTPServer{
Cfg: setting.NewCfg(),
pluginStore: pluginStore,
diff --git a/pkg/api/short_url_test.go b/pkg/api/short_url_test.go
index 752004738f2..bb592a9d30a 100644
--- a/pkg/api/short_url_test.go
+++ b/pkg/api/short_url_test.go
@@ -9,7 +9,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/shorturls"
@@ -54,8 +53,6 @@ func callCreateShortURL(sc *scenarioContext) {
func createShortURLScenario(t *testing.T, desc string, url string, routePattern string, cmd dtos.CreateShortURLCmd, shortURLService shorturls.Service, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- defer bus.ClearBusHandlers()
-
hs := HTTPServer{
Cfg: setting.NewCfg(),
ShortURLService: shortURLService,
diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go
index bc4df9b7ec9..fbf77357ad7 100644
--- a/pkg/api/user_test.go
+++ b/pkg/api/user_test.go
@@ -107,25 +107,6 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
}, mock)
loggedInUserScenario(t, "When calling GET on", "/api/users/lookup", "/api/users/lookup", func(sc *scenarioContext) {
- fakeNow := time.Date(2019, 2, 11, 17, 30, 40, 0, time.UTC)
- bus.AddHandler("test", func(ctx context.Context, query *models.GetUserByLoginQuery) error {
- require.Equal(t, "danlee", query.LoginOrEmail)
-
- query.Result = &models.User{
- Id: int64(1),
- Email: "daniel@grafana.com",
- Name: "Daniel",
- Login: "danlee",
- Theme: "light",
- IsAdmin: true,
- OrgId: int64(2),
- IsDisabled: false,
- Updated: fakeNow,
- Created: fakeNow,
- }
-
- return nil
- })
createUserCmd := models.CreateUserCommand{
Email: fmt.Sprint("admin", "@test.com"),
Name: "admin",
diff --git a/pkg/api/user_token_test.go b/pkg/api/user_token_test.go
index 37766385f13..4a07a6b737e 100644
--- a/pkg/api/user_token_test.go
+++ b/pkg/api/user_token_test.go
@@ -79,11 +79,6 @@ func TestUserTokenAPIEndpoint(t *testing.T) {
token := &models.UserToken{Id: 2}
mock := mockstore.NewSQLStoreMock()
revokeUserAuthTokenInternalScenario(t, "Should not be successful", cmd, testUserID, token, func(sc *scenarioContext) {
- bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error {
- cmd.Result = &models.User{Id: testUserID}
- return nil
- })
-
sc.userAuthTokenService.GetUserTokenProvider = func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) {
return token, nil
}
@@ -153,8 +148,6 @@ func TestUserTokenAPIEndpoint(t *testing.T) {
func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd,
userId int64, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
@@ -183,8 +176,6 @@ func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePat
func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, userId int64, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
@@ -212,8 +203,6 @@ func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePatte
func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId int64, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(desc, func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
hs := HTTPServer{
Bus: bus.GetBus(),
AuthTokenService: auth.NewFakeUserAuthTokenService(),
@@ -239,8 +228,6 @@ func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId
func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.RevokeAuthTokenCmd, userId int64,
token *models.UserToken, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(desc, func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
@@ -267,8 +254,6 @@ func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.R
func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *models.UserToken, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(desc, func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
hs := HTTPServer{
diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go
index e3f207ef814..28fdc4b002e 100644
--- a/pkg/bus/bus.go
+++ b/pkg/bus/bus.go
@@ -21,41 +21,17 @@ type Msg interface{}
// ErrHandlerNotFound defines an error if a handler is not found
var ErrHandlerNotFound = errors.New("handler not found")
-// TransactionManager defines a transaction interface
-type TransactionManager interface {
- InTransaction(ctx context.Context, fn func(ctx context.Context) error) error
-}
-
// Bus type defines the bus interface structure
type Bus interface {
- Dispatch(ctx context.Context, msg Msg) error
-
Publish(ctx context.Context, msg Msg) error
-
- // InTransaction starts a transaction and store it in the context.
- // The caller can then pass a function with multiple DispatchCtx calls that
- // all will be executed in the same transaction. InTransaction will rollback if the
- // callback returns an error.
- InTransaction(ctx context.Context, fn func(ctx context.Context) error) error
-
- AddHandler(handler HandlerFunc)
-
AddEventListener(handler HandlerFunc)
-
- // SetTransactionManager allows the user to replace the internal
- // noop TransactionManager that is responsible for managing
- // transactions in `InTransaction`
- SetTransactionManager(tm TransactionManager)
}
// InProcBus defines the bus structure
type InProcBus struct {
logger log.Logger
- handlers map[string]HandlerFunc
- handlersWithCtx map[string]HandlerFunc
listeners map[string][]HandlerFunc
listenersWithCtx map[string][]HandlerFunc
- txMng TransactionManager
tracer tracing.Tracer
}
@@ -64,11 +40,6 @@ func ProvideBus(tracer tracing.Tracer) *InProcBus {
return globalBus
}
-// InTransaction defines an in transaction function
-func (b *InProcBus) InTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
- return b.txMng.InTransaction(ctx, fn)
-}
-
// temp stuff, not sure how to handle bus instance, and init yet
var globalBus = New()
@@ -76,11 +47,8 @@ var globalBus = New()
func New() *InProcBus {
bus := &InProcBus{
logger: log.New("bus"),
- handlers: make(map[string]HandlerFunc),
- handlersWithCtx: make(map[string]HandlerFunc),
listeners: make(map[string][]HandlerFunc),
listenersWithCtx: make(map[string][]HandlerFunc),
- txMng: &noopTransactionManager{},
}
bus.tracer = tracing.InitializeForBus()
return bus
@@ -91,46 +59,6 @@ func GetBus() Bus {
return globalBus
}
-// SetTransactionManager function assign a transaction manager to the bus.
-func (b *InProcBus) SetTransactionManager(tm TransactionManager) {
- b.txMng = tm
-}
-
-// DispatchCtx function dispatch a message to the bus context.
-func (b *InProcBus) Dispatch(ctx context.Context, msg Msg) error {
- var msgName = reflect.TypeOf(msg).Elem().Name()
-
- ctx, span := b.tracer.Start(ctx, "bus - "+msgName)
- defer span.End()
-
- span.SetAttributes("msg", msgName, attribute.Key("msg").String(msgName))
-
- withCtx := true
- var handler = b.handlersWithCtx[msgName]
- if handler == nil {
- withCtx = false
- handler = b.handlers[msgName]
- if handler == nil {
- return ErrHandlerNotFound
- }
- }
-
- var params = []reflect.Value{}
- if withCtx {
- params = append(params, reflect.ValueOf(ctx))
- } else if setting.Env == setting.Dev {
- b.logger.Warn("DispatchCtx called with message handler registered using AddHandler and should be changed to use AddHandler", "msgName", msgName)
- }
- params = append(params, reflect.ValueOf(msg))
-
- ret := reflect.ValueOf(handler).Call(params)
- err := ret[0].Interface()
- if err == nil {
- return nil
- }
- return err.(error)
-}
-
// PublishCtx function publish a message to the bus listener.
func (b *InProcBus) Publish(ctx context.Context, msg Msg) error {
var msgName = reflect.TypeOf(msg).Elem().Name()
@@ -176,17 +104,6 @@ func callListeners(listeners []HandlerFunc, params []reflect.Value) error {
return nil
}
-func (b *InProcBus) AddHandler(handler HandlerFunc) {
- handlerType := reflect.TypeOf(handler)
- queryTypeName := handlerType.In(1).Elem().Name()
- b.handlersWithCtx[queryTypeName] = handler
-}
-
-// GetHandlerCtx returns the handler function for the given struct name.
-func (b *InProcBus) GetHandlerCtx(name string) HandlerFunc {
- return b.handlersWithCtx[name]
-}
-
func (b *InProcBus) AddEventListener(handler HandlerFunc) {
handlerType := reflect.TypeOf(handler)
eventName := handlerType.In(1).Elem().Name()
@@ -197,36 +114,12 @@ func (b *InProcBus) AddEventListener(handler HandlerFunc) {
b.listenersWithCtx[eventName] = append(b.listenersWithCtx[eventName], handler)
}
-// AddHandler attaches a handler function to the global bus context.
-// Package level function.
-func AddHandler(implName string, handler HandlerFunc) {
- globalBus.AddHandler(handler)
-}
-
// AddEventListenerCtx attaches a handler function to the event listener.
// Package level function.
func AddEventListener(handler HandlerFunc) {
globalBus.AddEventListener(handler)
}
-func Dispatch(ctx context.Context, msg Msg) error {
- return globalBus.Dispatch(ctx, msg)
-}
-
func Publish(ctx context.Context, msg Msg) error {
return globalBus.Publish(ctx, msg)
}
-
-func GetHandlerCtx(name string) HandlerFunc {
- return globalBus.GetHandlerCtx(name)
-}
-
-func ClearBusHandlers() {
- globalBus = New()
-}
-
-type noopTransactionManager struct{}
-
-func (*noopTransactionManager) InTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
- return fn(ctx)
-}
diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go
index 05789ef1f8b..3bb6a6bb86e 100644
--- a/pkg/bus/bus_test.go
+++ b/pkg/bus/bus_test.go
@@ -2,7 +2,6 @@ package bus
import (
"context"
- "errors"
"testing"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -14,140 +13,6 @@ type testQuery struct {
Resp string
}
-func TestDispatch(t *testing.T) {
- bus := New()
- tracer, err := tracing.InitializeTracerForTest()
- require.NoError(t, err)
- bus.tracer = tracer
-
- var invoked bool
-
- bus.AddHandler(func(ctx context.Context, query *testQuery) error {
- invoked = true
- return nil
- })
-
- require.NoError(t, err)
- err = bus.Dispatch(context.Background(), &testQuery{})
- require.NoError(t, err)
-
- require.True(t, invoked, "expected handler to be called")
-}
-
-func TestDispatch_NoRegisteredHandler(t *testing.T) {
- bus := New()
- tracer, err := tracing.InitializeTracerForTest()
- require.NoError(t, err)
- bus.tracer = tracer
-
- err = bus.Dispatch(context.Background(), &testQuery{})
- require.Equal(t, err, ErrHandlerNotFound,
- "expected bus to return HandlerNotFound since no handler is registered")
-}
-
-func TestDispatch_ContextHandler(t *testing.T) {
- bus := New()
- tracer, err := tracing.InitializeTracerForTest()
- require.NoError(t, err)
- bus.tracer = tracer
-
- var invoked bool
-
- bus.AddHandler(func(ctx context.Context, query *testQuery) error {
- invoked = true
- return nil
- })
-
- err = bus.Dispatch(context.Background(), &testQuery{})
- require.NoError(t, err)
-
- require.True(t, invoked, "expected handler to be called")
-}
-
-func TestDispatchCtx(t *testing.T) {
- bus := New()
- tracer, err := tracing.InitializeTracerForTest()
- require.NoError(t, err)
- bus.tracer = tracer
-
- var invoked bool
-
- bus.AddHandler(func(ctx context.Context, query *testQuery) error {
- invoked = true
- return nil
- })
-
- err = bus.Dispatch(context.Background(), &testQuery{})
- require.NoError(t, err)
-
- require.True(t, invoked, "expected handler to be called")
-}
-
-func TestDispatchCtx_NoContextHandler(t *testing.T) {
- bus := New()
- tracer, err := tracing.InitializeTracerForTest()
- require.NoError(t, err)
- bus.tracer = tracer
-
- var invoked bool
-
- bus.AddHandler(func(ctx context.Context, query *testQuery) error {
- invoked = true
- return nil
- })
-
- err = bus.Dispatch(context.Background(), &testQuery{})
- require.NoError(t, err)
-
- require.True(t, invoked, "expected handler to be called")
-}
-
-func TestDispatchCtx_NoRegisteredHandler(t *testing.T) {
- bus := New()
- tracer, err := tracing.InitializeTracerForTest()
- require.NoError(t, err)
- bus.tracer = tracer
-
- err = bus.Dispatch(context.Background(), &testQuery{})
- require.Equal(t, err, ErrHandlerNotFound,
- "expected bus to return HandlerNotFound since no handler is registered")
-}
-
-func TestQuery(t *testing.T) {
- bus := New()
- tracer, err := tracing.InitializeTracerForTest()
- require.NoError(t, err)
- bus.tracer = tracer
-
- want := "hello from handler"
-
- bus.AddHandler(func(ctx context.Context, q *testQuery) error {
- q.Resp = want
- return nil
- })
-
- q := &testQuery{}
-
- err = bus.Dispatch(context.Background(), q)
- require.NoError(t, err, "unable to dispatch query")
-
- require.Equal(t, want, q.Resp)
-}
-
-func TestQuery_HandlerReturnsError(t *testing.T) {
- bus := New()
- tracer, err := tracing.InitializeTracerForTest()
- require.NoError(t, err)
- bus.tracer = tracer
-
- bus.AddHandler(func(ctx context.Context, query *testQuery) error {
- return errors.New("handler error")
- })
-
- err = bus.Dispatch(context.Background(), &testQuery{})
- require.Error(t, err, "expected error but got none")
-}
-
func TestEventPublish(t *testing.T) {
bus := New()
tracer, err := tracing.InitializeTracerForTest()
diff --git a/pkg/expr/service_test.go b/pkg/expr/service_test.go
index e3c1e327c55..c47944c283a 100644
--- a/pkg/expr/service_test.go
+++ b/pkg/expr/service_test.go
@@ -10,8 +10,6 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
- "github.com/grafana/grafana/pkg/bus"
- "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
datasources "github.com/grafana/grafana/pkg/services/datasources/fakes"
"github.com/grafana/grafana/pkg/setting"
@@ -35,11 +33,6 @@ func TestService(t *testing.T) {
dataSourceService: &datasources.FakeDataSourceService{},
}
- bus.AddHandler("test", func(_ context.Context, query *models.GetDataSourceQuery) error {
- query.Result = &models.DataSource{Uid: "1", OrgId: 1, Type: "test", JsonData: simplejson.New()}
- return nil
- })
-
queries := []Query{
{
RefID: "A",
diff --git a/pkg/infra/usagestats/mock.go b/pkg/infra/usagestats/mock.go
index bead572f455..0510233fbb8 100644
--- a/pkg/infra/usagestats/mock.go
+++ b/pkg/infra/usagestats/mock.go
@@ -2,6 +2,7 @@ package usagestats
import (
"context"
+ "strings"
"testing"
"github.com/stretchr/testify/require"
@@ -29,8 +30,8 @@ func (usm *UsageStatsMock) GetUsageReport(ctx context.Context) (Report, error) {
return Report{Metrics: all}, nil
}
-func (usm *UsageStatsMock) ShouldBeReported(_ context.Context, _ string) bool {
- return true
+func (usm *UsageStatsMock) ShouldBeReported(_ context.Context, s string) bool {
+ return !strings.HasPrefix(s, "unknown")
}
func (usm *UsageStatsMock) RegisterSendReportCallback(_ SendReportCallbackFunc) {}
diff --git a/pkg/infra/usagestats/service/service.go b/pkg/infra/usagestats/service/service.go
index ceb8a321fbc..c31385d7d5a 100644
--- a/pkg/infra/usagestats/service/service.go
+++ b/pkg/infra/usagestats/service/service.go
@@ -2,48 +2,35 @@ package service
import (
"context"
- "fmt"
"time"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/usagestats"
- "github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/plugins"
- "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
)
type UsageStats struct {
Cfg *setting.Cfg
- SQLStore sqlstore.Store
- pluginStore plugins.Store
- SocialService social.Service
kvStore *kvstore.NamespacedKVStore
RouteRegister routing.RouteRegister
+ pluginStore plugins.Store
log log.Logger
- oauthProviders map[string]bool
- externalMetrics []usagestats.MetricsFunc
- concurrentUserStatsCache memoConcurrentUserStats
- startTime time.Time
- sendReportCallbacks []usagestats.SendReportCallbackFunc
+ externalMetrics []usagestats.MetricsFunc
+ sendReportCallbacks []usagestats.SendReportCallbackFunc
}
-func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore plugins.Store,
- socialService social.Service, kvStore kvstore.KVStore, routeRegister routing.RouteRegister,
-) *UsageStats {
+func ProvideService(cfg *setting.Cfg, pluginStore plugins.Store, kvStore kvstore.KVStore, routeRegister routing.RouteRegister) *UsageStats {
s := &UsageStats{
- Cfg: cfg,
- SQLStore: sqlStore,
- oauthProviders: socialService.GetOAuthProviders(),
- RouteRegister: routeRegister,
- pluginStore: pluginStore,
- kvStore: kvstore.WithNamespace(kvStore, 0, "infra.usagestats"),
- log: log.New("infra.usagestats"),
- startTime: time.Now(),
+ Cfg: cfg,
+ RouteRegister: routeRegister,
+ pluginStore: pluginStore,
+ kvStore: kvstore.WithNamespace(kvStore, 0, "infra.usagestats"),
+ log: log.New("infra.usagestats"),
}
s.registerAPIEndpoints()
@@ -52,8 +39,6 @@ func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore p
}
func (uss *UsageStats) Run(ctx context.Context) error {
- uss.updateTotalStats(ctx)
-
// try to load last sent time from kv store
lastSent := time.Now()
if val, ok, err := uss.kvStore.Get(ctx, "last_sent"); err != nil {
@@ -74,10 +59,8 @@ func (uss *UsageStats) Run(ctx context.Context) error {
}
sendReportTicker := time.NewTicker(nextSendInterval)
- updateStatsTicker := time.NewTicker(time.Minute * 30)
defer sendReportTicker.Stop()
- defer updateStatsTicker.Stop()
for {
select {
@@ -99,54 +82,21 @@ func (uss *UsageStats) Run(ctx context.Context) error {
for _, callback := range uss.sendReportCallbacks {
callback()
}
- case <-updateStatsTicker.C:
- uss.updateTotalStats(ctx)
case <-ctx.Done():
return ctx.Err()
}
}
}
-type memoConcurrentUserStats struct {
- stats *concurrentUsersStats
-
- memoized time.Time
-}
-
-const concurrentUserStatsCacheLifetime = time.Hour
-
-func (uss *UsageStats) GetConcurrentUsersStats(ctx context.Context) (*concurrentUsersStats, error) {
- memoizationPeriod := time.Now().Add(-concurrentUserStatsCacheLifetime)
- if !uss.concurrentUserStatsCache.memoized.Before(memoizationPeriod) {
- return uss.concurrentUserStatsCache.stats, nil
- }
-
- uss.concurrentUserStatsCache.stats = &concurrentUsersStats{}
- err := uss.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
- // Retrieves concurrent users stats as a histogram. Buckets are accumulative and upper bound is inclusive.
- rawSQL := `
-SELECT
- COUNT(CASE WHEN tokens <= 3 THEN 1 END) AS bucket_le_3,
- COUNT(CASE WHEN tokens <= 6 THEN 1 END) AS bucket_le_6,
- COUNT(CASE WHEN tokens <= 9 THEN 1 END) AS bucket_le_9,
- COUNT(CASE WHEN tokens <= 12 THEN 1 END) AS bucket_le_12,
- COUNT(CASE WHEN tokens <= 15 THEN 1 END) AS bucket_le_15,
- COUNT(1) AS bucket_le_inf
-FROM (select count(1) as tokens from user_auth_token group by user_id) uat;`
- _, err := sess.SQL(rawSQL).Get(uss.concurrentUserStatsCache.stats)
- if err != nil {
- return err
- }
- return nil
- })
- if err != nil {
- return nil, fmt.Errorf("failed to get concurrent users stats from database: %w", err)
- }
-
- uss.concurrentUserStatsCache.memoized = time.Now()
- return uss.concurrentUserStatsCache.stats, nil
-}
-
func (uss *UsageStats) RegisterSendReportCallback(c usagestats.SendReportCallbackFunc) {
uss.sendReportCallbacks = append(uss.sendReportCallbacks, c)
}
+
+func (uss *UsageStats) ShouldBeReported(ctx context.Context, dsType string) bool {
+ ds, exists := uss.pluginStore.Plugin(ctx, dsType)
+ if !exists {
+ return false
+ }
+
+ return ds.Signature.IsValid() || ds.Signature.IsInternal()
+}
diff --git a/pkg/infra/usagestats/service/types.go b/pkg/infra/usagestats/service/types.go
deleted file mode 100644
index c9e2a4d8361..00000000000
--- a/pkg/infra/usagestats/service/types.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package service
-
-type concurrentUsersStats struct {
- BucketLE3 int32 `xorm:"bucket_le_3"`
- BucketLE6 int32 `xorm:"bucket_le_6"`
- BucketLE9 int32 `xorm:"bucket_le_9"`
- BucketLE12 int32 `xorm:"bucket_le_12"`
- BucketLE15 int32 `xorm:"bucket_le_15"`
- BucketLEInf int32 `xorm:"bucket_le_inf"`
-}
diff --git a/pkg/infra/usagestats/service/usage_stats.go b/pkg/infra/usagestats/service/usage_stats.go
index b67e1db0aa9..95df118a6c0 100644
--- a/pkg/infra/usagestats/service/usage_stats.go
+++ b/pkg/infra/usagestats/service/usage_stats.go
@@ -4,17 +4,14 @@ import (
"bytes"
"context"
"encoding/json"
- "fmt"
"net/http"
"runtime"
"strings"
"time"
"github.com/google/uuid"
- "github.com/grafana/grafana/pkg/infra/metrics"
+
"github.com/grafana/grafana/pkg/infra/usagestats"
- "github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/plugins"
)
var usageStatsURL = "https://stats.grafana.org/grafana-usage-report"
@@ -38,64 +35,7 @@ func (uss *UsageStats) GetUsageReport(ctx context.Context) (usagestats.Report, e
UsageStatsId: uss.GetUsageStatsId(ctx),
}
- statsQuery := models.GetSystemStatsQuery{}
- if err := uss.SQLStore.GetSystemStats(ctx, &statsQuery); err != nil {
- uss.log.Error("Failed to get system stats", "error", err)
- return report, err
- }
-
- metrics["stats.dashboards.count"] = statsQuery.Result.Dashboards
- metrics["stats.users.count"] = statsQuery.Result.Users
- metrics["stats.admins.count"] = statsQuery.Result.Admins
- metrics["stats.editors.count"] = statsQuery.Result.Editors
- metrics["stats.viewers.count"] = statsQuery.Result.Viewers
- metrics["stats.orgs.count"] = statsQuery.Result.Orgs
- metrics["stats.playlist.count"] = statsQuery.Result.Playlists
- metrics["stats.plugins.apps.count"] = uss.appCount(ctx)
- metrics["stats.plugins.panels.count"] = uss.panelCount(ctx)
- metrics["stats.plugins.datasources.count"] = uss.dataSourceCount(ctx)
- metrics["stats.alerts.count"] = statsQuery.Result.Alerts
- metrics["stats.active_users.count"] = statsQuery.Result.ActiveUsers
- metrics["stats.active_admins.count"] = statsQuery.Result.ActiveAdmins
- metrics["stats.active_editors.count"] = statsQuery.Result.ActiveEditors
- metrics["stats.active_viewers.count"] = statsQuery.Result.ActiveViewers
- metrics["stats.active_sessions.count"] = statsQuery.Result.ActiveSessions
- metrics["stats.monthly_active_users.count"] = statsQuery.Result.MonthlyActiveUsers
- metrics["stats.daily_active_users.count"] = statsQuery.Result.DailyActiveUsers
- metrics["stats.daily_active_admins.count"] = statsQuery.Result.DailyActiveAdmins
- metrics["stats.daily_active_editors.count"] = statsQuery.Result.DailyActiveEditors
- metrics["stats.daily_active_viewers.count"] = statsQuery.Result.DailyActiveViewers
- metrics["stats.daily_active_sessions.count"] = statsQuery.Result.DailyActiveSessions
- metrics["stats.datasources.count"] = statsQuery.Result.Datasources
- metrics["stats.stars.count"] = statsQuery.Result.Stars
- metrics["stats.folders.count"] = statsQuery.Result.Folders
- metrics["stats.dashboard_permissions.count"] = statsQuery.Result.DashboardPermissions
- metrics["stats.folder_permissions.count"] = statsQuery.Result.FolderPermissions
- metrics["stats.provisioned_dashboards.count"] = statsQuery.Result.ProvisionedDashboards
- metrics["stats.snapshots.count"] = statsQuery.Result.Snapshots
- metrics["stats.teams.count"] = statsQuery.Result.Teams
- metrics["stats.total_auth_token.count"] = statsQuery.Result.AuthTokens
- metrics["stats.dashboard_versions.count"] = statsQuery.Result.DashboardVersions
- metrics["stats.annotations.count"] = statsQuery.Result.Annotations
- metrics["stats.alert_rules.count"] = statsQuery.Result.AlertRules
- metrics["stats.library_panels.count"] = statsQuery.Result.LibraryPanels
- metrics["stats.library_variables.count"] = statsQuery.Result.LibraryVariables
- metrics["stats.dashboards_viewers_can_edit.count"] = statsQuery.Result.DashboardsViewersCanEdit
- metrics["stats.dashboards_viewers_can_admin.count"] = statsQuery.Result.DashboardsViewersCanAdmin
- metrics["stats.folders_viewers_can_edit.count"] = statsQuery.Result.FoldersViewersCanEdit
- metrics["stats.folders_viewers_can_admin.count"] = statsQuery.Result.FoldersViewersCanAdmin
- metrics["stats.api_keys.count"] = statsQuery.Result.APIKeys
-
- ossEditionCount := 1
- enterpriseEditionCount := 0
- if uss.Cfg.IsEnterprise {
- enterpriseEditionCount = 1
- ossEditionCount = 0
- }
- metrics["stats.edition.oss.count"] = ossEditionCount
- metrics["stats.edition.enterprise.count"] = enterpriseEditionCount
-
- uss.registerExternalMetrics(ctx, metrics)
+ uss.gatherMetrics(ctx, metrics)
// must run after registration of external metrics
if v, ok := metrics["stats.valid_license.count"]; ok {
@@ -104,136 +44,10 @@ func (uss *UsageStats) GetUsageReport(ctx context.Context) (usagestats.Report, e
metrics["stats.valid_license.count"] = 0
}
- userCount := statsQuery.Result.Users
- avgAuthTokensPerUser := statsQuery.Result.AuthTokens
- if userCount != 0 {
- avgAuthTokensPerUser /= userCount
- }
-
- metrics["stats.avg_auth_token_per_user.count"] = avgAuthTokensPerUser
-
- dsStats := models.GetDataSourceStatsQuery{}
- if err := uss.SQLStore.GetDataSourceStats(ctx, &dsStats); err != nil {
- uss.log.Error("Failed to get datasource stats", "error", err)
- return report, err
- }
-
- // send counters for each data source
- // but ignore any custom data sources
- // as sending that name could be sensitive information
- dsOtherCount := 0
- for _, dsStat := range dsStats.Result {
- if uss.ShouldBeReported(ctx, dsStat.Type) {
- metrics["stats.ds."+dsStat.Type+".count"] = dsStat.Count
- } else {
- dsOtherCount += dsStat.Count
- }
- }
- metrics["stats.ds.other.count"] = dsOtherCount
-
- esDataSourcesQuery := models.GetDataSourcesByTypeQuery{Type: models.DS_ES}
- if err := uss.SQLStore.GetDataSourcesByType(ctx, &esDataSourcesQuery); err != nil {
- uss.log.Error("Failed to get elasticsearch json data", "error", err)
- return report, err
- }
-
- for _, data := range esDataSourcesQuery.Result {
- esVersion, err := data.JsonData.Get("esVersion").Int()
- if err != nil {
- continue
- }
-
- statName := fmt.Sprintf("stats.ds.elasticsearch.v%d.count", esVersion)
-
- count, _ := metrics[statName].(int64)
-
- metrics[statName] = count + 1
- }
-
- metrics["stats.packaging."+uss.Cfg.Packaging+".count"] = 1
- metrics["stats.distributor."+uss.Cfg.ReportingDistributor+".count"] = 1
-
- // fetch datasource access stats
- dsAccessStats := models.GetDataSourceAccessStatsQuery{}
- if err := uss.SQLStore.GetDataSourceAccessStats(ctx, &dsAccessStats); err != nil {
- uss.log.Error("Failed to get datasource access stats", "error", err)
- return report, err
- }
-
- // send access counters for each data source
- // but ignore any custom data sources
- // as sending that name could be sensitive information
- dsAccessOtherCount := make(map[string]int64)
- for _, dsAccessStat := range dsAccessStats.Result {
- if dsAccessStat.Access == "" {
- continue
- }
-
- access := strings.ToLower(dsAccessStat.Access)
-
- if uss.ShouldBeReported(ctx, dsAccessStat.Type) {
- metrics["stats.ds_access."+dsAccessStat.Type+"."+access+".count"] = dsAccessStat.Count
- } else {
- old := dsAccessOtherCount[access]
- dsAccessOtherCount[access] = old + dsAccessStat.Count
- }
- }
-
- for access, count := range dsAccessOtherCount {
- metrics["stats.ds_access.other."+access+".count"] = count
- }
-
- // get stats about alert notifier usage
- anStats := models.GetAlertNotifierUsageStatsQuery{}
- if err := uss.SQLStore.GetAlertNotifiersUsageStats(ctx, &anStats); err != nil {
- uss.log.Error("Failed to get alert notification stats", "error", err)
- return report, err
- }
-
- for _, stats := range anStats.Result {
- metrics["stats.alert_notifiers."+stats.Type+".count"] = stats.Count
- }
-
- // Add stats about auth configuration
- authTypes := map[string]bool{}
- authTypes["anonymous"] = uss.Cfg.AnonymousEnabled
- authTypes["basic_auth"] = uss.Cfg.BasicAuthEnabled
- authTypes["ldap"] = uss.Cfg.LDAPEnabled
- authTypes["auth_proxy"] = uss.Cfg.AuthProxyEnabled
-
- for provider, enabled := range uss.oauthProviders {
- authTypes["oauth_"+provider] = enabled
- }
-
- for authType, enabled := range authTypes {
- enabledValue := 0
- if enabled {
- enabledValue = 1
- }
- metrics["stats.auth_enabled."+authType+".count"] = enabledValue
- }
-
- // Get concurrent users stats as histogram
- concurrentUsersStats, err := uss.GetConcurrentUsersStats(ctx)
- if err != nil {
- uss.log.Error("Failed to get concurrent users stats", "error", err)
- return report, err
- }
-
- // Histogram is cumulative and metric name has a postfix of le_""
- metrics["stats.auth_token_per_user_le_3"] = concurrentUsersStats.BucketLE3
- metrics["stats.auth_token_per_user_le_6"] = concurrentUsersStats.BucketLE6
- metrics["stats.auth_token_per_user_le_9"] = concurrentUsersStats.BucketLE9
- metrics["stats.auth_token_per_user_le_12"] = concurrentUsersStats.BucketLE12
- metrics["stats.auth_token_per_user_le_15"] = concurrentUsersStats.BucketLE15
- metrics["stats.auth_token_per_user_le_inf"] = concurrentUsersStats.BucketLEInf
-
- metrics["stats.uptime"] = int64(time.Since(uss.startTime).Seconds())
-
return report, nil
}
-func (uss *UsageStats) registerExternalMetrics(ctx context.Context, metrics map[string]interface{}) {
+func (uss *UsageStats) gatherMetrics(ctx context.Context, metrics map[string]interface{}) {
for _, fn := range uss.externalMetrics {
fnMetrics, err := fn(ctx)
if err != nil {
@@ -290,55 +104,6 @@ var sendUsageStats = func(uss *UsageStats, data *bytes.Buffer) {
}()
}
-func (uss *UsageStats) updateTotalStats(ctx context.Context) {
- if !uss.Cfg.MetricsEndpointEnabled || uss.Cfg.MetricsEndpointDisableTotalStats {
- return
- }
-
- statsQuery := models.GetSystemStatsQuery{}
- if err := uss.SQLStore.GetSystemStats(ctx, &statsQuery); err != nil {
- uss.log.Error("Failed to get system stats", "error", err)
- return
- }
-
- metrics.MStatTotalDashboards.Set(float64(statsQuery.Result.Dashboards))
- metrics.MStatTotalFolders.Set(float64(statsQuery.Result.Folders))
- metrics.MStatTotalUsers.Set(float64(statsQuery.Result.Users))
- metrics.MStatActiveUsers.Set(float64(statsQuery.Result.ActiveUsers))
- metrics.MStatTotalPlaylists.Set(float64(statsQuery.Result.Playlists))
- metrics.MStatTotalOrgs.Set(float64(statsQuery.Result.Orgs))
- metrics.StatsTotalViewers.Set(float64(statsQuery.Result.Viewers))
- metrics.StatsTotalActiveViewers.Set(float64(statsQuery.Result.ActiveViewers))
- metrics.StatsTotalEditors.Set(float64(statsQuery.Result.Editors))
- metrics.StatsTotalActiveEditors.Set(float64(statsQuery.Result.ActiveEditors))
- metrics.StatsTotalAdmins.Set(float64(statsQuery.Result.Admins))
- metrics.StatsTotalActiveAdmins.Set(float64(statsQuery.Result.ActiveAdmins))
- metrics.StatsTotalDashboardVersions.Set(float64(statsQuery.Result.DashboardVersions))
- metrics.StatsTotalAnnotations.Set(float64(statsQuery.Result.Annotations))
- metrics.StatsTotalAlertRules.Set(float64(statsQuery.Result.AlertRules))
- metrics.StatsTotalLibraryPanels.Set(float64(statsQuery.Result.LibraryPanels))
- metrics.StatsTotalLibraryVariables.Set(float64(statsQuery.Result.LibraryVariables))
-
- dsStats := models.GetDataSourceStatsQuery{}
- if err := uss.SQLStore.GetDataSourceStats(ctx, &dsStats); err != nil {
- uss.log.Error("Failed to get datasource stats", "error", err)
- return
- }
-
- for _, dsStat := range dsStats.Result {
- metrics.StatsTotalDataSources.WithLabelValues(dsStat.Type).Set(float64(dsStat.Count))
- }
-}
-
-func (uss *UsageStats) ShouldBeReported(ctx context.Context, dsType string) bool {
- ds, exists := uss.pluginStore.Plugin(ctx, dsType)
- if !exists {
- return false
- }
-
- return ds.Signature.IsValid() || ds.Signature.IsInternal()
-}
-
func (uss *UsageStats) GetUsageStatsId(ctx context.Context) string {
anonId, ok, err := uss.kvStore.Get(ctx, "anonymous_id")
if err != nil {
@@ -366,15 +131,3 @@ func (uss *UsageStats) GetUsageStatsId(ctx context.Context) string {
return anonId
}
-
-func (uss *UsageStats) appCount(ctx context.Context) int {
- return len(uss.pluginStore.Plugins(ctx, plugins.App))
-}
-
-func (uss *UsageStats) panelCount(ctx context.Context) int {
- return len(uss.pluginStore.Plugins(ctx, plugins.Panel))
-}
-
-func (uss *UsageStats) dataSourceCount(ctx context.Context) int {
- return len(uss.pluginStore.Plugins(ctx, plugins.DataSource))
-}
diff --git a/pkg/infra/usagestats/service/usage_stats_test.go b/pkg/infra/usagestats/service/usage_stats_test.go
index 13acd1155aa..7c36eead42e 100644
--- a/pkg/infra/usagestats/service/usage_stats_test.go
+++ b/pkg/infra/usagestats/service/usage_stats_test.go
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
+ "encoding/json"
"errors"
"io/ioutil"
"net/http"
@@ -11,18 +12,16 @@ import (
"testing"
"time"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/kvstore"
- "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/usagestats"
- "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
"github.com/grafana/grafana/pkg/setting"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
// This is to ensure that the interface contract is held by the implementation
@@ -37,411 +36,163 @@ func Test_InterfaceContractValidity(t *testing.T) {
}
func TestMetrics(t *testing.T) {
- t.Run("When sending usage stats", func(t *testing.T) {
- sqlStore := mockstore.NewSQLStoreMock()
- uss := createService(t, setting.Cfg{}, sqlStore, false)
+ const metricName = "stats.test_metric.count"
- sqlStore.ExpectedSystemStats = &models.SystemStats{
- Dashboards: 1,
- Datasources: 2,
- Users: 3,
- Admins: 31,
- Editors: 32,
- Viewers: 33,
- ActiveUsers: 4,
- ActiveAdmins: 21,
- ActiveEditors: 22,
- ActiveViewers: 23,
- ActiveSessions: 24,
- DailyActiveUsers: 25,
- DailyActiveAdmins: 26,
- DailyActiveEditors: 27,
- DailyActiveViewers: 28,
- DailyActiveSessions: 29,
- Orgs: 5,
- Playlists: 6,
- Alerts: 7,
- Stars: 8,
- Folders: 9,
- DashboardPermissions: 10,
- FolderPermissions: 11,
- ProvisionedDashboards: 12,
- Snapshots: 13,
- Teams: 14,
- AuthTokens: 15,
- DashboardVersions: 16,
- Annotations: 17,
- AlertRules: 18,
- LibraryPanels: 19,
- LibraryVariables: 20,
- DashboardsViewersCanAdmin: 3,
- DashboardsViewersCanEdit: 2,
- FoldersViewersCanAdmin: 1,
- FoldersViewersCanEdit: 5,
- APIKeys: 2,
+ sqlStore := mockstore.NewSQLStoreMock()
+ uss := createService(t, setting.Cfg{}, sqlStore, false)
+
+ uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) {
+ return map[string]interface{}{metricName: 1}, nil
+ })
+
+ err := uss.sendUsageStats(context.Background())
+ require.NoError(t, err)
+
+ t.Run("Given reporting not enabled and sending usage stats", func(t *testing.T) {
+ origSendUsageStats := sendUsageStats
+ t.Cleanup(func() {
+ sendUsageStats = origSendUsageStats
+ })
+ statsSent := false
+ sendUsageStats = func(uss *UsageStats, b *bytes.Buffer) {
+ statsSent = true
}
- setupSomeDataSourcePlugins(t, uss)
+ uss.Cfg.ReportingEnabled = false
+ err := uss.sendUsageStats(context.Background())
+ require.NoError(t, err)
- sqlStore.ExpectedDataSourceStats = []*models.DataSourceStats{
- {
- Type: models.DS_ES,
- Count: 9,
- },
- {
- Type: models.DS_PROMETHEUS,
- Count: 10,
- },
- {
- Type: "unknown_ds",
- Count: 11,
- },
- {
- Type: "unknown_ds2",
- Count: 12,
- },
+ require.False(t, statsSent)
+ })
+
+ t.Run("Given reporting enabled, stats should be gathered and sent to HTTP endpoint", func(t *testing.T) {
+ origCfg := uss.Cfg
+ t.Cleanup(func() {
+ uss.Cfg = origCfg
+ })
+ uss.Cfg = &setting.Cfg{
+ ReportingEnabled: true,
+ BuildVersion: "5.0.0",
+ AnonymousEnabled: true,
+ BasicAuthEnabled: true,
+ LDAPEnabled: true,
+ AuthProxyEnabled: true,
+ Packaging: "deb",
+ ReportingDistributor: "hosted-grafana",
}
- sqlStore.ExpectedDataSources = []*models.DataSource{
- {
- JsonData: simplejson.NewFromAny(map[string]interface{}{
- "esVersion": 2,
- }),
- },
- {
- JsonData: simplejson.NewFromAny(map[string]interface{}{
- "esVersion": 2,
- }),
- },
- {
- JsonData: simplejson.NewFromAny(map[string]interface{}{
- "esVersion": 70,
- }),
- },
- }
-
- sqlStore.ExpectedDataSourcesAccessStats = []*models.DataSourceAccessStats{
- {
- Type: models.DS_ES,
- Access: "direct",
- Count: 1,
- },
- {
- Type: models.DS_ES,
- Access: "proxy",
- Count: 2,
- },
- {
- Type: models.DS_PROMETHEUS,
- Access: "proxy",
- Count: 3,
- },
- {
- Type: "unknown_ds",
- Access: "proxy",
- Count: 4,
- },
- {
- Type: "unknown_ds2",
- Access: "",
- Count: 5,
- },
- {
- Type: "unknown_ds3",
- Access: "direct",
- Count: 6,
- },
- {
- Type: "unknown_ds4",
- Access: "direct",
- Count: 7,
- },
- {
- Type: "unknown_ds5",
- Access: "proxy",
- Count: 8,
- },
- }
-
- sqlStore.ExpectedNotifierUsageStats = []*models.NotifierUsageStats{
- {
- Type: "slack",
- Count: 1,
- },
- {
- Type: "webhook",
- Count: 2,
- },
- }
-
- uss.SQLStore = sqlStore
-
- createConcurrentTokens(t, uss.SQLStore)
-
- uss.oauthProviders = map[string]bool{
- "github": true,
- "gitlab": true,
- "azuread": true,
- "google": true,
- "generic_oauth": true,
- "grafana_com": true,
- }
+ ch := make(chan httpResp)
+ ticker := time.NewTicker(2 * time.Second)
+ ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ buf, err := ioutil.ReadAll(r.Body)
+ if err != nil {
+ t.Logf("Fake HTTP handler received an error: %s", err.Error())
+ ch <- httpResp{
+ err: err,
+ }
+ return
+ }
+ require.NoError(t, err, "Failed to read response body, err=%v", err)
+ t.Logf("Fake HTTP handler received a response")
+ ch <- httpResp{
+ responseBuffer: bytes.NewBuffer(buf),
+ req: r,
+ }
+ }))
+ t.Cleanup(ts.Close)
+ t.Cleanup(func() {
+ close(ch)
+ })
+ usageStatsURL = ts.URL
err := uss.sendUsageStats(context.Background())
require.NoError(t, err)
- t.Run("Given reporting not enabled and sending usage stats", func(t *testing.T) {
- origSendUsageStats := sendUsageStats
- t.Cleanup(func() {
- sendUsageStats = origSendUsageStats
- })
- statsSent := false
- sendUsageStats = func(uss *UsageStats, b *bytes.Buffer) {
- statsSent = true
- }
+ // Wait for fake HTTP server to receive a request
+ var resp httpResp
+ select {
+ case resp = <-ch:
+ require.NoError(t, resp.err, "Fake server experienced an error")
+ case <-ticker.C:
+ t.Fatalf("Timed out waiting for HTTP request")
+ }
- uss.Cfg.ReportingEnabled = false
- err := uss.sendUsageStats(context.Background())
- require.NoError(t, err)
+ t.Logf("Received response from fake HTTP server: %+v\n", resp)
- require.False(t, statsSent)
- })
+ assert.NotNil(t, resp.req)
- t.Run("Given reporting enabled, stats should be gathered and sent to HTTP endpoint", func(t *testing.T) {
- origCfg := uss.Cfg
- t.Cleanup(func() {
- uss.Cfg = origCfg
- })
- uss.Cfg = &setting.Cfg{
- ReportingEnabled: true,
- BuildVersion: "5.0.0",
- AnonymousEnabled: true,
- BasicAuthEnabled: true,
- LDAPEnabled: true,
- AuthProxyEnabled: true,
- Packaging: "deb",
- ReportingDistributor: "hosted-grafana",
- }
+ assert.Equal(t, http.MethodPost, resp.req.Method)
+ assert.Equal(t, "application/json", resp.req.Header.Get("Content-Type"))
- ch := make(chan httpResp)
- ticker := time.NewTicker(2 * time.Second)
- ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- buf, err := ioutil.ReadAll(r.Body)
- if err != nil {
- t.Logf("Fake HTTP handler received an error: %s", err.Error())
- ch <- httpResp{
- err: err,
- }
- return
- }
- require.NoError(t, err, "Failed to read response body, err=%v", err)
- t.Logf("Fake HTTP handler received a response")
- ch <- httpResp{
- responseBuffer: bytes.NewBuffer(buf),
- req: r,
- }
- }))
- t.Cleanup(ts.Close)
- t.Cleanup(func() {
- close(ch)
- })
- usageStatsURL = ts.URL
+ require.NotNil(t, resp.responseBuffer)
- err := uss.sendUsageStats(context.Background())
- require.NoError(t, err)
+ j := make(map[string]interface{})
+ err = json.Unmarshal(resp.responseBuffer.Bytes(), &j)
+ require.NoError(t, err)
- // Wait for fake HTTP server to receive a request
- var resp httpResp
- select {
- case resp = <-ch:
- require.NoError(t, resp.err, "Fake server experienced an error")
- case <-ticker.C:
- t.Fatalf("Timed out waiting for HTTP request")
- }
+ assert.Equal(t, "5_0_0", j["version"])
+ assert.Equal(t, runtime.GOOS, j["os"])
+ assert.Equal(t, runtime.GOARCH, j["arch"])
- t.Logf("Received response from fake HTTP server: %+v\n", resp)
+ usageId := uss.GetUsageStatsId(context.Background())
+ assert.NotEmpty(t, usageId)
- assert.NotNil(t, resp.req)
+ metrics, ok := j["metrics"].(map[string]interface{})
+ require.True(t, ok)
+ assert.EqualValues(t, 1, metrics[metricName])
+ })
+}
- assert.Equal(t, http.MethodPost, resp.req.Method)
- assert.Equal(t, "application/json", resp.req.Header.Get("Content-Type"))
+func TestGetUsageReport_IncludesMetrics(t *testing.T) {
+ sqlStore := mockstore.NewSQLStoreMock()
+ uss := createService(t, setting.Cfg{}, sqlStore, true)
+ metricName := "stats.test_metric.count"
- require.NotNil(t, resp.responseBuffer)
-
- j, err := simplejson.NewFromReader(resp.responseBuffer)
- require.NoError(t, err)
-
- assert.Equal(t, "5_0_0", j.Get("version").MustString())
- assert.Equal(t, runtime.GOOS, j.Get("os").MustString())
- assert.Equal(t, runtime.GOARCH, j.Get("arch").MustString())
-
- usageId := uss.GetUsageStatsId(context.Background())
- assert.NotEmpty(t, usageId)
-
- metrics := j.Get("metrics")
- assert.Equal(t, 15, metrics.Get("stats.total_auth_token.count").MustInt())
- assert.Equal(t, 2, metrics.Get("stats.api_keys.count").MustInt())
- assert.Equal(t, 5, metrics.Get("stats.avg_auth_token_per_user.count").MustInt())
- assert.Equal(t, 16, metrics.Get("stats.dashboard_versions.count").MustInt())
- assert.Equal(t, 17, metrics.Get("stats.annotations.count").MustInt())
- assert.Equal(t, 18, metrics.Get("stats.alert_rules.count").MustInt())
- assert.Equal(t, 19, metrics.Get("stats.library_panels.count").MustInt())
- assert.Equal(t, 20, metrics.Get("stats.library_variables.count").MustInt())
- assert.Equal(t, 0, metrics.Get("stats.live_users.count").MustInt())
- assert.Equal(t, 0, metrics.Get("stats.live_clients.count").MustInt())
-
- assert.Equal(t, 9, metrics.Get("stats.ds."+models.DS_ES+".count").MustInt())
- assert.Equal(t, 10, metrics.Get("stats.ds."+models.DS_PROMETHEUS+".count").MustInt())
-
- assert.Equal(t, 11+12, metrics.Get("stats.ds.other.count").MustInt())
-
- assert.Equal(t, 1, metrics.Get("stats.ds_access."+models.DS_ES+".direct.count").MustInt())
- assert.Equal(t, 2, metrics.Get("stats.ds_access."+models.DS_ES+".proxy.count").MustInt())
- assert.Equal(t, 3, metrics.Get("stats.ds_access."+models.DS_PROMETHEUS+".proxy.count").MustInt())
- assert.Equal(t, 6+7, metrics.Get("stats.ds_access.other.direct.count").MustInt())
- assert.Equal(t, 4+8, metrics.Get("stats.ds_access.other.proxy.count").MustInt())
-
- assert.Equal(t, 1, metrics.Get("stats.alert_notifiers.slack.count").MustInt())
- assert.Equal(t, 2, metrics.Get("stats.alert_notifiers.webhook.count").MustInt())
-
- assert.Equal(t, 1, metrics.Get("stats.auth_enabled.anonymous.count").MustInt())
- assert.Equal(t, 1, metrics.Get("stats.auth_enabled.basic_auth.count").MustInt())
- assert.Equal(t, 1, metrics.Get("stats.auth_enabled.ldap.count").MustInt())
- assert.Equal(t, 1, metrics.Get("stats.auth_enabled.auth_proxy.count").MustInt())
- assert.Equal(t, 1, metrics.Get("stats.auth_enabled.oauth_github.count").MustInt())
- assert.Equal(t, 1, metrics.Get("stats.auth_enabled.oauth_gitlab.count").MustInt())
- assert.Equal(t, 1, metrics.Get("stats.auth_enabled.oauth_google.count").MustInt())
- assert.Equal(t, 1, metrics.Get("stats.auth_enabled.oauth_azuread.count").MustInt())
- assert.Equal(t, 1, metrics.Get("stats.auth_enabled.oauth_generic_oauth.count").MustInt())
- assert.Equal(t, 1, metrics.Get("stats.auth_enabled.oauth_grafana_com.count").MustInt())
-
- assert.Equal(t, 1, metrics.Get("stats.packaging.deb.count").MustInt())
- assert.Equal(t, 1, metrics.Get("stats.distributor.hosted-grafana.count").MustInt())
-
- assert.LessOrEqual(t, 60, metrics.Get("stats.uptime").MustInt())
- assert.Greater(t, 70, metrics.Get("stats.uptime").MustInt())
- })
+ uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) {
+ return map[string]interface{}{metricName: 1}, nil
})
- t.Run("When updating total stats", func(t *testing.T) {
- sqlStore := mockstore.NewSQLStoreMock()
- uss := createService(t, setting.Cfg{}, sqlStore, false)
- uss.Cfg.MetricsEndpointEnabled = true
- uss.Cfg.MetricsEndpointDisableTotalStats = false
+ report, err := uss.GetUsageReport(context.Background())
+ require.NoError(t, err, "Expected no error")
- sqlStore.ExpectedSystemStats = &models.SystemStats{}
+ metric := report.Metrics[metricName]
+ assert.Equal(t, 1, metric)
+}
- t.Run("When metrics is disabled and total stats is enabled, stats should not be updated", func(t *testing.T) {
- uss.Cfg.MetricsEndpointEnabled = false
- uss.Cfg.MetricsEndpointDisableTotalStats = false
- uss.updateTotalStats(context.Background())
- })
+func TestRegisterMetrics(t *testing.T) {
+ const goodMetricName = "stats.test_external_metric.count"
- t.Run("When metrics is enabled and total stats is disabled, stats should not be updated", func(t *testing.T) {
- uss.Cfg.MetricsEndpointEnabled = true
- uss.Cfg.MetricsEndpointDisableTotalStats = true
+ sqlStore := mockstore.NewSQLStoreMock()
+ uss := createService(t, setting.Cfg{}, sqlStore, false)
+ metrics := map[string]interface{}{"stats.test_metric.count": 1, "stats.test_metric_second.count": 2}
- uss.updateTotalStats(context.Background())
- })
-
- t.Run("When metrics is disabled and total stats is disabled, stats should not be updated", func(t *testing.T) {
- uss.Cfg.MetricsEndpointEnabled = false
- uss.Cfg.MetricsEndpointDisableTotalStats = true
-
- uss.updateTotalStats(context.Background())
- })
-
- t.Run("When metrics is enabled and total stats is enabled, stats should be updated", func(t *testing.T) {
- uss.Cfg.MetricsEndpointEnabled = true
- uss.Cfg.MetricsEndpointDisableTotalStats = false
-
- uss.updateTotalStats(context.Background())
- })
+ uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) {
+ return map[string]interface{}{goodMetricName: 1}, nil
})
- t.Run("When registering a metric", func(t *testing.T) {
- sqlStore := mockstore.NewSQLStoreMock()
- uss := createService(t, setting.Cfg{}, sqlStore, false)
- metricName := "stats.test_metric.count"
+ {
+ extMetrics, err := uss.externalMetrics[0](context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, map[string]interface{}{goodMetricName: 1}, extMetrics)
+ }
- t.Run("Adds a new metric to the external metrics", func(t *testing.T) {
- uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) {
- return map[string]interface{}{metricName: 1}, nil
- })
+ uss.gatherMetrics(context.Background(), metrics)
+ assert.Equal(t, 1, metrics[goodMetricName])
- metrics, err := uss.externalMetrics[0](context.Background())
- require.NoError(t, err)
- assert.Equal(t, map[string]interface{}{metricName: 1}, metrics)
- })
- })
-
- t.Run("When getting usage report", func(t *testing.T) {
- sqlStore := mockstore.NewSQLStoreMock()
- uss := createService(t, setting.Cfg{}, sqlStore, true)
- metricName := "stats.test_metric.count"
-
- createConcurrentTokens(t, uss.SQLStore)
-
- t.Run("Should include metrics for concurrent users", func(t *testing.T) {
- report, err := uss.GetUsageReport(context.Background())
- require.NoError(t, err)
-
- assert.Equal(t, int32(1), report.Metrics["stats.auth_token_per_user_le_3"])
- assert.Equal(t, int32(2), report.Metrics["stats.auth_token_per_user_le_6"])
- assert.Equal(t, int32(3), report.Metrics["stats.auth_token_per_user_le_9"])
- assert.Equal(t, int32(4), report.Metrics["stats.auth_token_per_user_le_12"])
- assert.Equal(t, int32(5), report.Metrics["stats.auth_token_per_user_le_15"])
- assert.Equal(t, int32(6), report.Metrics["stats.auth_token_per_user_le_inf"])
- })
-
- t.Run("Should include external metrics", func(t *testing.T) {
- uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) {
- return map[string]interface{}{metricName: 1}, nil
- })
-
- report, err := uss.GetUsageReport(context.Background())
- require.NoError(t, err, "Expected no error")
-
- metric := report.Metrics[metricName]
- assert.Equal(t, 1, metric)
- })
- })
-
- t.Run("When registering external metrics", func(t *testing.T) {
- sqlStore := mockstore.NewSQLStoreMock()
- uss := createService(t, setting.Cfg{}, sqlStore, false)
- metrics := map[string]interface{}{"stats.test_metric.count": 1, "stats.test_metric_second.count": 2}
- extMetricName := "stats.test_external_metric.count"
+ t.Run("do not add metrics that return an error when fetched", func(t *testing.T) {
+ const badMetricName = "stats.test_external_metric_error.count"
uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) {
- return map[string]interface{}{extMetricName: 1}, nil
+ return map[string]interface{}{badMetricName: 1}, errors.New("some error")
})
+ uss.gatherMetrics(context.Background(), metrics)
- uss.registerExternalMetrics(context.Background(), metrics)
+ extErrorMetric := metrics[badMetricName]
+ extMetric := metrics[goodMetricName]
- assert.Equal(t, 1, metrics[extMetricName])
-
- t.Run("When loading a metric results to an error", func(t *testing.T) {
- uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) {
- return map[string]interface{}{extMetricName: 1}, nil
- })
- extErrorMetricName := "stats.test_external_metric_error.count"
-
- t.Run("Should not add it to metrics", func(t *testing.T) {
- uss.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) {
- return map[string]interface{}{extErrorMetricName: 1}, errors.New("some error")
- })
-
- uss.registerExternalMetrics(context.Background(), metrics)
-
- extErrorMetric := metrics[extErrorMetricName]
- extMetric := metrics[extMetricName]
-
- require.Nil(t, extErrorMetric, "Invalid metric should not be added")
- assert.Equal(t, 1, extMetric)
- assert.Len(t, metrics, 3, "Expected only one available metric")
- })
- })
+ require.Nil(t, extErrorMetric, "Invalid metric should not be added")
+ assert.Equal(t, 1, extMetric)
+ assert.Len(t, metrics, 3, "Expected only one available metric")
})
}
@@ -470,27 +221,6 @@ func (pr fakePluginStore) Plugins(_ context.Context, pluginTypes ...plugins.Type
return result
}
-func setupSomeDataSourcePlugins(t *testing.T, uss *UsageStats) {
- t.Helper()
-
- uss.pluginStore = &fakePluginStore{
- plugins: map[string]plugins.PluginDTO{
- models.DS_ES: {
- Signature: "internal",
- },
- models.DS_PROMETHEUS: {
- Signature: "internal",
- },
- models.DS_GRAPHITE: {
- Signature: "internal",
- },
- models.DS_MYSQL: {
- Signature: "internal",
- },
- },
- }
-}
-
type httpResp struct {
req *http.Request
responseBuffer *bytes.Buffer
@@ -502,14 +232,11 @@ func createService(t *testing.T, cfg setting.Cfg, sqlStore sqlstore.Store, withD
if withDB {
sqlStore = sqlstore.InitTestDB(t)
}
- return &UsageStats{
- Cfg: &cfg,
- SQLStore: sqlStore,
- externalMetrics: make([]usagestats.MetricsFunc, 0),
- pluginStore: &fakePluginStore{},
- kvStore: kvstore.WithNamespace(kvstore.ProvideService(sqlStore), 0, "infra.usagestats"),
- log: log.New("infra.usagestats"),
- startTime: time.Now().Add(-1 * time.Minute),
- RouteRegister: routing.NewRouteRegister(),
- }
+
+ return ProvideService(
+ &cfg,
+ &fakePluginStore{},
+ kvstore.ProvideService(sqlStore),
+ routing.NewRouteRegister(),
+ )
}
diff --git a/pkg/infra/usagestats/statscollector/concurrent_users.go b/pkg/infra/usagestats/statscollector/concurrent_users.go
new file mode 100644
index 00000000000..55d23881ebc
--- /dev/null
+++ b/pkg/infra/usagestats/statscollector/concurrent_users.go
@@ -0,0 +1,58 @@
+package statscollector
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+)
+
+const concurrentUserStatsCacheLifetime = time.Hour
+
+type concurrentUsersStats struct {
+ BucketLE3 int32 `xorm:"bucket_le_3"`
+ BucketLE6 int32 `xorm:"bucket_le_6"`
+ BucketLE9 int32 `xorm:"bucket_le_9"`
+ BucketLE12 int32 `xorm:"bucket_le_12"`
+ BucketLE15 int32 `xorm:"bucket_le_15"`
+ BucketLEInf int32 `xorm:"bucket_le_inf"`
+}
+
+type memoConcurrentUserStats struct {
+ stats *concurrentUsersStats
+
+ memoized time.Time
+}
+
+func (s *Service) concurrentUsers(ctx context.Context) (*concurrentUsersStats, error) {
+ memoizationPeriod := time.Now().Add(-concurrentUserStatsCacheLifetime)
+ if !s.concurrentUserStatsCache.memoized.Before(memoizationPeriod) {
+ return s.concurrentUserStatsCache.stats, nil
+ }
+
+ s.concurrentUserStatsCache.stats = &concurrentUsersStats{}
+ err := s.sqlstore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
+ // Retrieves concurrent users stats as a histogram. Buckets are accumulative and upper bound is inclusive.
+ rawSQL := `
+SELECT
+ COUNT(CASE WHEN tokens <= 3 THEN 1 END) AS bucket_le_3,
+ COUNT(CASE WHEN tokens <= 6 THEN 1 END) AS bucket_le_6,
+ COUNT(CASE WHEN tokens <= 9 THEN 1 END) AS bucket_le_9,
+ COUNT(CASE WHEN tokens <= 12 THEN 1 END) AS bucket_le_12,
+ COUNT(CASE WHEN tokens <= 15 THEN 1 END) AS bucket_le_15,
+ COUNT(1) AS bucket_le_inf
+FROM (select count(1) as tokens from user_auth_token group by user_id) uat;`
+ _, err := sess.SQL(rawSQL).Get(s.concurrentUserStatsCache.stats)
+ if err != nil {
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to get concurrent users stats from database: %w", err)
+ }
+
+ s.concurrentUserStatsCache.memoized = time.Now()
+ return s.concurrentUserStatsCache.stats, nil
+}
diff --git a/pkg/infra/usagestats/service/usage_stats_service_test.go b/pkg/infra/usagestats/statscollector/concurrent_users_test.go
similarity index 57%
rename from pkg/infra/usagestats/service/usage_stats_service_test.go
rename to pkg/infra/usagestats/statscollector/concurrent_users_test.go
index 5960c0fb8c6..dfa027d0b85 100644
--- a/pkg/infra/usagestats/service/usage_stats_service_test.go
+++ b/pkg/infra/usagestats/statscollector/concurrent_users_test.go
@@ -1,4 +1,4 @@
-package service
+package statscollector
import (
"context"
@@ -8,21 +8,35 @@ import (
"testing"
"time"
- "github.com/grafana/grafana/pkg/infra/kvstore"
- "github.com/grafana/grafana/pkg/infra/log"
- "github.com/grafana/grafana/pkg/services/sqlstore"
- "github.com/grafana/grafana/pkg/util"
+ "github.com/grafana/grafana/pkg/setting"
+
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/util"
)
-func TestUsageStatsService_GetConcurrentUsersStats(t *testing.T) {
+func TestConcurrentUsersMetrics(t *testing.T) {
sqlStore := sqlstore.InitTestDB(t)
- uss := &UsageStats{
- SQLStore: sqlStore,
- kvStore: kvstore.WithNamespace(kvstore.ProvideService(sqlStore), 0, "infra.usagestats"),
- log: log.New("infra.usagestats"),
- }
+ s := createService(t, setting.NewCfg(), sqlStore)
+
+ createConcurrentTokens(t, sqlStore)
+
+ stats, err := s.collect(context.Background())
+ require.NoError(t, err)
+
+ assert.Equal(t, int32(1), stats["stats.auth_token_per_user_le_3"])
+ assert.Equal(t, int32(2), stats["stats.auth_token_per_user_le_6"])
+ assert.Equal(t, int32(3), stats["stats.auth_token_per_user_le_9"])
+ assert.Equal(t, int32(4), stats["stats.auth_token_per_user_le_12"])
+ assert.Equal(t, int32(5), stats["stats.auth_token_per_user_le_15"])
+ assert.Equal(t, int32(6), stats["stats.auth_token_per_user_le_inf"])
+}
+
+func TestConcurrentUsersStats(t *testing.T) {
+ sqlStore := sqlstore.InitTestDB(t)
+ s := createService(t, setting.NewCfg(), sqlStore)
createConcurrentTokens(t, sqlStore)
@@ -31,10 +45,10 @@ func TestUsageStatsService_GetConcurrentUsersStats(t *testing.T) {
cancel()
})
- actualResult, err := uss.GetConcurrentUsersStats(ctx)
+ actualResult, err := s.concurrentUsers(ctx)
require.NoError(t, err)
- expectedCachedResult := &concurrentUsersStats{
+ expectedResult := &concurrentUsersStats{
BucketLE3: 1,
BucketLE6: 2,
BucketLE9: 3,
@@ -42,14 +56,39 @@ func TestUsageStatsService_GetConcurrentUsersStats(t *testing.T) {
BucketLE15: 5,
BucketLEInf: 6,
}
- assert.Equal(t, expectedCachedResult, actualResult)
+ assert.Equal(t, expectedResult, actualResult)
createToken(t, 8, sqlStore)
require.NoError(t, err)
- actualResult, err = uss.GetConcurrentUsersStats(ctx)
+ // Cached, new token is not reflected yet.
+ actualResult, err = s.concurrentUsers(ctx)
require.NoError(t, err)
- assert.Equal(t, expectedCachedResult, actualResult)
+ assert.Equal(t, expectedResult, actualResult)
+
+ // Bust cache
+ s.concurrentUserStatsCache = memoConcurrentUserStats{}
+
+ expectedResult = &concurrentUsersStats{
+ BucketLE3: 2,
+ BucketLE6: 3,
+ BucketLE9: 4,
+ BucketLE12: 5,
+ BucketLE15: 6,
+ BucketLEInf: 7,
+ }
+ actualResult, err = s.concurrentUsers(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, expectedResult, actualResult)
+}
+
+func createConcurrentTokens(t *testing.T, sqlStore sqlstore.Store) {
+ t.Helper()
+ for u := 1; u <= 6; u++ {
+ for tkn := 1; tkn <= u*3; tkn++ {
+ createToken(t, u, sqlStore)
+ }
+ }
}
func createToken(t *testing.T, uID int, sqlStore sqlstore.Store) {
@@ -83,15 +122,6 @@ func createToken(t *testing.T, uID int, sqlStore sqlstore.Store) {
require.NoError(t, err)
}
-func createConcurrentTokens(t *testing.T, sqlStore sqlstore.Store) {
- t.Helper()
- for u := 1; u <= 6; u++ {
- for tkn := 1; tkn <= u*3; tkn++ {
- createToken(t, u, sqlStore)
- }
- }
-}
-
type userAuthToken struct {
UserID int64 `xorm:"user_id"`
AuthToken string
diff --git a/pkg/infra/usagestats/statscollector/service.go b/pkg/infra/usagestats/statscollector/service.go
new file mode 100644
index 00000000000..b96abfb4e68
--- /dev/null
+++ b/pkg/infra/usagestats/statscollector/service.go
@@ -0,0 +1,321 @@
+package statscollector
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+
+ "github.com/grafana/grafana/pkg/infra/usagestats"
+
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/metrics"
+ "github.com/grafana/grafana/pkg/login/social"
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/plugins"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+type Service struct {
+ cfg *setting.Cfg
+ sqlstore sqlstore.Store
+ plugins plugins.Store
+ social social.Service
+ usageStats usagestats.Service
+ features *featuremgmt.FeatureManager
+
+ log log.Logger
+
+ startTime time.Time
+ concurrentUserStatsCache memoConcurrentUserStats
+}
+
+func ProvideService(
+ usagestats usagestats.Service,
+ cfg *setting.Cfg,
+ store sqlstore.Store,
+ social social.Service,
+ plugins plugins.Store,
+ features *featuremgmt.FeatureManager,
+) *Service {
+ s := &Service{
+ cfg: cfg,
+ sqlstore: store,
+ plugins: plugins,
+ social: social,
+ usageStats: usagestats,
+ features: features,
+
+ startTime: time.Now(),
+ log: log.New("infra.usagestats.collector"),
+ }
+
+ usagestats.RegisterMetricsFunc(s.collect)
+
+ return s
+}
+
+func (s *Service) Run(ctx context.Context) error {
+ s.updateTotalStats(ctx)
+ updateStatsTicker := time.NewTicker(time.Minute * 30)
+ defer updateStatsTicker.Stop()
+
+ for {
+ select {
+ case <-updateStatsTicker.C:
+ s.updateTotalStats(ctx)
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+}
+
+func (s *Service) collect(ctx context.Context) (map[string]interface{}, error) {
+ m := map[string]interface{}{}
+
+ statsQuery := models.GetSystemStatsQuery{}
+ if err := s.sqlstore.GetSystemStats(ctx, &statsQuery); err != nil {
+ s.log.Error("Failed to get system stats", "error", err)
+ return nil, err
+ }
+
+ m["stats.dashboards.count"] = statsQuery.Result.Dashboards
+ m["stats.users.count"] = statsQuery.Result.Users
+ m["stats.admins.count"] = statsQuery.Result.Admins
+ m["stats.editors.count"] = statsQuery.Result.Editors
+ m["stats.viewers.count"] = statsQuery.Result.Viewers
+ m["stats.orgs.count"] = statsQuery.Result.Orgs
+ m["stats.playlist.count"] = statsQuery.Result.Playlists
+ m["stats.plugins.apps.count"] = s.appCount(ctx)
+ m["stats.plugins.panels.count"] = s.panelCount(ctx)
+ m["stats.plugins.datasources.count"] = s.dataSourceCount(ctx)
+ m["stats.alerts.count"] = statsQuery.Result.Alerts
+ m["stats.active_users.count"] = statsQuery.Result.ActiveUsers
+ m["stats.active_admins.count"] = statsQuery.Result.ActiveAdmins
+ m["stats.active_editors.count"] = statsQuery.Result.ActiveEditors
+ m["stats.active_viewers.count"] = statsQuery.Result.ActiveViewers
+ m["stats.active_sessions.count"] = statsQuery.Result.ActiveSessions
+ m["stats.monthly_active_users.count"] = statsQuery.Result.MonthlyActiveUsers
+ m["stats.daily_active_users.count"] = statsQuery.Result.DailyActiveUsers
+ m["stats.daily_active_admins.count"] = statsQuery.Result.DailyActiveAdmins
+ m["stats.daily_active_editors.count"] = statsQuery.Result.DailyActiveEditors
+ m["stats.daily_active_viewers.count"] = statsQuery.Result.DailyActiveViewers
+ m["stats.daily_active_sessions.count"] = statsQuery.Result.DailyActiveSessions
+ m["stats.datasources.count"] = statsQuery.Result.Datasources
+ m["stats.stars.count"] = statsQuery.Result.Stars
+ m["stats.folders.count"] = statsQuery.Result.Folders
+ m["stats.dashboard_permissions.count"] = statsQuery.Result.DashboardPermissions
+ m["stats.folder_permissions.count"] = statsQuery.Result.FolderPermissions
+ m["stats.provisioned_dashboards.count"] = statsQuery.Result.ProvisionedDashboards
+ m["stats.snapshots.count"] = statsQuery.Result.Snapshots
+ m["stats.teams.count"] = statsQuery.Result.Teams
+ m["stats.total_auth_token.count"] = statsQuery.Result.AuthTokens
+ m["stats.dashboard_versions.count"] = statsQuery.Result.DashboardVersions
+ m["stats.annotations.count"] = statsQuery.Result.Annotations
+ m["stats.alert_rules.count"] = statsQuery.Result.AlertRules
+ m["stats.library_panels.count"] = statsQuery.Result.LibraryPanels
+ m["stats.library_variables.count"] = statsQuery.Result.LibraryVariables
+ m["stats.dashboards_viewers_can_edit.count"] = statsQuery.Result.DashboardsViewersCanEdit
+ m["stats.dashboards_viewers_can_admin.count"] = statsQuery.Result.DashboardsViewersCanAdmin
+ m["stats.folders_viewers_can_edit.count"] = statsQuery.Result.FoldersViewersCanEdit
+ m["stats.folders_viewers_can_admin.count"] = statsQuery.Result.FoldersViewersCanAdmin
+ m["stats.api_keys.count"] = statsQuery.Result.APIKeys
+
+ ossEditionCount := 1
+ enterpriseEditionCount := 0
+ if s.cfg.IsEnterprise {
+ enterpriseEditionCount = 1
+ ossEditionCount = 0
+ }
+ m["stats.edition.oss.count"] = ossEditionCount
+ m["stats.edition.enterprise.count"] = enterpriseEditionCount
+
+ userCount := statsQuery.Result.Users
+ avgAuthTokensPerUser := statsQuery.Result.AuthTokens
+ if userCount != 0 {
+ avgAuthTokensPerUser /= userCount
+ }
+
+ m["stats.avg_auth_token_per_user.count"] = avgAuthTokensPerUser
+
+ dsStats := models.GetDataSourceStatsQuery{}
+ if err := s.sqlstore.GetDataSourceStats(ctx, &dsStats); err != nil {
+ s.log.Error("Failed to get datasource stats", "error", err)
+ return nil, err
+ }
+
+ // send counters for each data source
+ // but ignore any custom data sources
+ // as sending that name could be sensitive information
+ dsOtherCount := 0
+ for _, dsStat := range dsStats.Result {
+ if s.usageStats.ShouldBeReported(ctx, dsStat.Type) {
+ m["stats.ds."+dsStat.Type+".count"] = dsStat.Count
+ } else {
+ dsOtherCount += dsStat.Count
+ }
+ }
+ m["stats.ds.other.count"] = dsOtherCount
+
+ esDataSourcesQuery := models.GetDataSourcesByTypeQuery{Type: models.DS_ES}
+ if err := s.sqlstore.GetDataSourcesByType(ctx, &esDataSourcesQuery); err != nil {
+ s.log.Error("Failed to get elasticsearch json data", "error", err)
+ return nil, err
+ }
+
+ for _, data := range esDataSourcesQuery.Result {
+ esVersion, err := data.JsonData.Get("esVersion").Int()
+ if err != nil {
+ continue
+ }
+
+ statName := fmt.Sprintf("stats.ds.elasticsearch.v%d.count", esVersion)
+
+ count, _ := m[statName].(int64)
+
+ m[statName] = count + 1
+ }
+
+ m["stats.packaging."+s.cfg.Packaging+".count"] = 1
+ m["stats.distributor."+s.cfg.ReportingDistributor+".count"] = 1
+
+ // fetch datasource access stats
+ dsAccessStats := models.GetDataSourceAccessStatsQuery{}
+ if err := s.sqlstore.GetDataSourceAccessStats(ctx, &dsAccessStats); err != nil {
+ s.log.Error("Failed to get datasource access stats", "error", err)
+ return nil, err
+ }
+
+ // send access counters for each data source
+ // but ignore any custom data sources
+ // as sending that name could be sensitive information
+ dsAccessOtherCount := make(map[string]int64)
+ for _, dsAccessStat := range dsAccessStats.Result {
+ if dsAccessStat.Access == "" {
+ continue
+ }
+
+ access := strings.ToLower(dsAccessStat.Access)
+
+ if s.usageStats.ShouldBeReported(ctx, dsAccessStat.Type) {
+ m["stats.ds_access."+dsAccessStat.Type+"."+access+".count"] = dsAccessStat.Count
+ } else {
+ old := dsAccessOtherCount[access]
+ dsAccessOtherCount[access] = old + dsAccessStat.Count
+ }
+ }
+
+ for access, count := range dsAccessOtherCount {
+ m["stats.ds_access.other."+access+".count"] = count
+ }
+
+ // get stats about alert notifier usage
+ anStats := models.GetAlertNotifierUsageStatsQuery{}
+ if err := s.sqlstore.GetAlertNotifiersUsageStats(ctx, &anStats); err != nil {
+ s.log.Error("Failed to get alert notification stats", "error", err)
+ return nil, err
+ }
+
+ for _, stats := range anStats.Result {
+ m["stats.alert_notifiers."+stats.Type+".count"] = stats.Count
+ }
+
+ // Add stats about auth configuration
+ authTypes := map[string]bool{}
+ authTypes["anonymous"] = s.cfg.AnonymousEnabled
+ authTypes["basic_auth"] = s.cfg.BasicAuthEnabled
+ authTypes["ldap"] = s.cfg.LDAPEnabled
+ authTypes["auth_proxy"] = s.cfg.AuthProxyEnabled
+
+ for provider, enabled := range s.social.GetOAuthProviders() {
+ authTypes["oauth_"+provider] = enabled
+ }
+
+ for authType, enabled := range authTypes {
+ enabledValue := 0
+ if enabled {
+ enabledValue = 1
+ }
+ m["stats.auth_enabled."+authType+".count"] = enabledValue
+ }
+
+ // Get concurrent users stats as histogram
+ concurrentUsersStats, err := s.concurrentUsers(ctx)
+ if err != nil {
+ s.log.Error("Failed to get concurrent users stats", "error", err)
+ return nil, err
+ }
+
+ // Histogram is cumulative and metric name has a postfix of le_""
+ m["stats.auth_token_per_user_le_3"] = concurrentUsersStats.BucketLE3
+ m["stats.auth_token_per_user_le_6"] = concurrentUsersStats.BucketLE6
+ m["stats.auth_token_per_user_le_9"] = concurrentUsersStats.BucketLE9
+ m["stats.auth_token_per_user_le_12"] = concurrentUsersStats.BucketLE12
+ m["stats.auth_token_per_user_le_15"] = concurrentUsersStats.BucketLE15
+ m["stats.auth_token_per_user_le_inf"] = concurrentUsersStats.BucketLEInf
+
+ m["stats.uptime"] = int64(time.Since(s.startTime).Seconds())
+
+ featureUsageStats := s.features.GetUsageStats(ctx)
+ for k, v := range featureUsageStats {
+ m[k] = v
+ }
+
+ return m, nil
+}
+
+func (s *Service) updateTotalStats(ctx context.Context) bool {
+ if !s.cfg.MetricsEndpointEnabled || s.cfg.MetricsEndpointDisableTotalStats {
+ return false
+ }
+
+ statsQuery := models.GetSystemStatsQuery{}
+ if err := s.sqlstore.GetSystemStats(ctx, &statsQuery); err != nil {
+ s.log.Error("Failed to get system stats", "error", err)
+ return false
+ }
+
+ metrics.MStatTotalDashboards.Set(float64(statsQuery.Result.Dashboards))
+ metrics.MStatTotalFolders.Set(float64(statsQuery.Result.Folders))
+ metrics.MStatTotalUsers.Set(float64(statsQuery.Result.Users))
+ metrics.MStatActiveUsers.Set(float64(statsQuery.Result.ActiveUsers))
+ metrics.MStatTotalPlaylists.Set(float64(statsQuery.Result.Playlists))
+ metrics.MStatTotalOrgs.Set(float64(statsQuery.Result.Orgs))
+ metrics.StatsTotalViewers.Set(float64(statsQuery.Result.Viewers))
+ metrics.StatsTotalActiveViewers.Set(float64(statsQuery.Result.ActiveViewers))
+ metrics.StatsTotalEditors.Set(float64(statsQuery.Result.Editors))
+ metrics.StatsTotalActiveEditors.Set(float64(statsQuery.Result.ActiveEditors))
+ metrics.StatsTotalAdmins.Set(float64(statsQuery.Result.Admins))
+ metrics.StatsTotalActiveAdmins.Set(float64(statsQuery.Result.ActiveAdmins))
+ metrics.StatsTotalDashboardVersions.Set(float64(statsQuery.Result.DashboardVersions))
+ metrics.StatsTotalAnnotations.Set(float64(statsQuery.Result.Annotations))
+ metrics.StatsTotalAlertRules.Set(float64(statsQuery.Result.AlertRules))
+ metrics.StatsTotalLibraryPanels.Set(float64(statsQuery.Result.LibraryPanels))
+ metrics.StatsTotalLibraryVariables.Set(float64(statsQuery.Result.LibraryVariables))
+
+ dsStats := models.GetDataSourceStatsQuery{}
+ if err := s.sqlstore.GetDataSourceStats(ctx, &dsStats); err != nil {
+ s.log.Error("Failed to get datasource stats", "error", err)
+ return true
+ }
+
+ for _, dsStat := range dsStats.Result {
+ metrics.StatsTotalDataSources.WithLabelValues(dsStat.Type).Set(float64(dsStat.Count))
+ }
+ return true
+}
+
+func (s *Service) appCount(ctx context.Context) int {
+ return len(s.plugins.Plugins(ctx, plugins.App))
+}
+
+func (s *Service) panelCount(ctx context.Context) int {
+ return len(s.plugins.Plugins(ctx, plugins.Panel))
+}
+
+func (s *Service) dataSourceCount(ctx context.Context) int {
+ return len(s.plugins.Plugins(ctx, plugins.DataSource))
+}
diff --git a/pkg/infra/usagestats/statscollector/service_test.go b/pkg/infra/usagestats/statscollector/service_test.go
new file mode 100644
index 00000000000..6ff76c947bd
--- /dev/null
+++ b/pkg/infra/usagestats/statscollector/service_test.go
@@ -0,0 +1,362 @@
+package statscollector
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/infra/usagestats"
+ "github.com/grafana/grafana/pkg/login/social"
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/plugins"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+func TestTotalStatsUpdate(t *testing.T) {
+ sqlStore := mockstore.NewSQLStoreMock()
+ s := createService(t, setting.NewCfg(), sqlStore)
+ s.cfg.MetricsEndpointEnabled = true
+ s.cfg.MetricsEndpointDisableTotalStats = false
+
+ sqlStore.ExpectedSystemStats = &models.SystemStats{}
+
+ tests := []struct {
+ MetricsEndpointEnabled bool
+ MetricsEndpointDisableTotalStats bool
+ ExpectedUpdate bool
+ }{
+ {
+ MetricsEndpointEnabled: false,
+ MetricsEndpointDisableTotalStats: false,
+ ExpectedUpdate: false,
+ },
+ {
+ MetricsEndpointEnabled: false,
+ MetricsEndpointDisableTotalStats: true,
+ ExpectedUpdate: false,
+ },
+ {
+ MetricsEndpointEnabled: true,
+ MetricsEndpointDisableTotalStats: true,
+ ExpectedUpdate: false,
+ },
+ {
+ MetricsEndpointEnabled: true,
+ MetricsEndpointDisableTotalStats: false,
+ ExpectedUpdate: true,
+ },
+ }
+
+ for _, tc := range tests {
+ tc := tc
+
+ t.Run(fmt.Sprintf(
+ "metricsEnabled(%v) * totalStatsDisabled(%v) = %v",
+ tc.MetricsEndpointEnabled,
+ tc.MetricsEndpointDisableTotalStats,
+ tc.ExpectedUpdate,
+ ), func(t *testing.T) {
+ s.cfg.MetricsEndpointEnabled = tc.MetricsEndpointEnabled
+ s.cfg.MetricsEndpointDisableTotalStats = tc.MetricsEndpointDisableTotalStats
+
+ assert.Equal(t, tc.ExpectedUpdate, s.updateTotalStats(context.Background()))
+ })
+ }
+}
+
+func TestFeatureUsageStats(t *testing.T) {
+ store := mockstore.NewSQLStoreMock()
+ mockSystemStats(store)
+ s := createService(t, setting.NewCfg(), store)
+
+ m, err := s.collect(context.Background())
+ require.NoError(t, err, "Expected no error")
+
+ assert.Equal(t, 1, m["stats.features.feature_1.count"])
+ assert.Equal(t, 1, m["stats.features.feature_2.count"])
+}
+
+func TestCollectingUsageStats(t *testing.T) {
+ sqlStore := mockstore.NewSQLStoreMock()
+ s := createService(t, &setting.Cfg{
+ ReportingEnabled: true,
+ BuildVersion: "5.0.0",
+ AnonymousEnabled: true,
+ BasicAuthEnabled: true,
+ LDAPEnabled: true,
+ AuthProxyEnabled: true,
+ Packaging: "deb",
+ ReportingDistributor: "hosted-grafana",
+ }, sqlStore)
+
+ s.startTime = time.Now().Add(-1 * time.Minute)
+
+ mockSystemStats(sqlStore)
+ setupSomeDataSourcePlugins(t, s)
+
+ sqlStore.ExpectedDataSourceStats = []*models.DataSourceStats{
+ {
+ Type: models.DS_ES,
+ Count: 9,
+ },
+ {
+ Type: models.DS_PROMETHEUS,
+ Count: 10,
+ },
+ {
+ Type: "unknown_ds",
+ Count: 11,
+ },
+ {
+ Type: "unknown_ds2",
+ Count: 12,
+ },
+ }
+
+ sqlStore.ExpectedDataSources = []*models.DataSource{
+ {
+ JsonData: simplejson.NewFromAny(map[string]interface{}{
+ "esVersion": 2,
+ }),
+ },
+ {
+ JsonData: simplejson.NewFromAny(map[string]interface{}{
+ "esVersion": 2,
+ }),
+ },
+ {
+ JsonData: simplejson.NewFromAny(map[string]interface{}{
+ "esVersion": 70,
+ }),
+ },
+ }
+
+ sqlStore.ExpectedDataSourcesAccessStats = []*models.DataSourceAccessStats{
+ {
+ Type: models.DS_ES,
+ Access: "direct",
+ Count: 1,
+ },
+ {
+ Type: models.DS_ES,
+ Access: "proxy",
+ Count: 2,
+ },
+ {
+ Type: models.DS_PROMETHEUS,
+ Access: "proxy",
+ Count: 3,
+ },
+ {
+ Type: "unknown_ds",
+ Access: "proxy",
+ Count: 4,
+ },
+ {
+ Type: "unknown_ds2",
+ Access: "",
+ Count: 5,
+ },
+ {
+ Type: "unknown_ds3",
+ Access: "direct",
+ Count: 6,
+ },
+ {
+ Type: "unknown_ds4",
+ Access: "direct",
+ Count: 7,
+ },
+ {
+ Type: "unknown_ds5",
+ Access: "proxy",
+ Count: 8,
+ },
+ }
+
+ sqlStore.ExpectedNotifierUsageStats = []*models.NotifierUsageStats{
+ {
+ Type: "slack",
+ Count: 1,
+ },
+ {
+ Type: "webhook",
+ Count: 2,
+ },
+ }
+
+ createConcurrentTokens(t, sqlStore)
+
+ s.social = &mockSocial{
+ OAuthProviders: map[string]bool{
+ "github": true,
+ "gitlab": true,
+ "azuread": true,
+ "google": true,
+ "generic_oauth": true,
+ "grafana_com": true,
+ },
+ }
+
+ metrics, err := s.collect(context.Background())
+ require.NoError(t, err)
+
+ assert.EqualValues(t, 15, metrics["stats.total_auth_token.count"])
+ assert.EqualValues(t, 2, metrics["stats.api_keys.count"])
+ assert.EqualValues(t, 5, metrics["stats.avg_auth_token_per_user.count"])
+ assert.EqualValues(t, 16, metrics["stats.dashboard_versions.count"])
+ assert.EqualValues(t, 17, metrics["stats.annotations.count"])
+ assert.EqualValues(t, 18, metrics["stats.alert_rules.count"])
+ assert.EqualValues(t, 19, metrics["stats.library_panels.count"])
+ assert.EqualValues(t, 20, metrics["stats.library_variables.count"])
+
+ assert.EqualValues(t, 9, metrics["stats.ds."+models.DS_ES+".count"])
+ assert.EqualValues(t, 10, metrics["stats.ds."+models.DS_PROMETHEUS+".count"])
+
+ assert.EqualValues(t, 11+12, metrics["stats.ds.other.count"])
+
+ assert.EqualValues(t, 1, metrics["stats.ds_access."+models.DS_ES+".direct.count"])
+ assert.EqualValues(t, 2, metrics["stats.ds_access."+models.DS_ES+".proxy.count"])
+ assert.EqualValues(t, 3, metrics["stats.ds_access."+models.DS_PROMETHEUS+".proxy.count"])
+ assert.EqualValues(t, 6+7, metrics["stats.ds_access.other.direct.count"])
+ assert.EqualValues(t, 4+8, metrics["stats.ds_access.other.proxy.count"])
+
+ assert.EqualValues(t, 1, metrics["stats.alert_notifiers.slack.count"])
+ assert.EqualValues(t, 2, metrics["stats.alert_notifiers.webhook.count"])
+
+ assert.EqualValues(t, 1, metrics["stats.auth_enabled.anonymous.count"])
+ assert.EqualValues(t, 1, metrics["stats.auth_enabled.basic_auth.count"])
+ assert.EqualValues(t, 1, metrics["stats.auth_enabled.ldap.count"])
+ assert.EqualValues(t, 1, metrics["stats.auth_enabled.auth_proxy.count"])
+ assert.EqualValues(t, 1, metrics["stats.auth_enabled.oauth_github.count"])
+ assert.EqualValues(t, 1, metrics["stats.auth_enabled.oauth_gitlab.count"])
+ assert.EqualValues(t, 1, metrics["stats.auth_enabled.oauth_google.count"])
+ assert.EqualValues(t, 1, metrics["stats.auth_enabled.oauth_azuread.count"])
+ assert.EqualValues(t, 1, metrics["stats.auth_enabled.oauth_generic_oauth.count"])
+ assert.EqualValues(t, 1, metrics["stats.auth_enabled.oauth_grafana_com.count"])
+
+ assert.EqualValues(t, 1, metrics["stats.packaging.deb.count"])
+ assert.EqualValues(t, 1, metrics["stats.distributor.hosted-grafana.count"])
+
+ assert.InDelta(t, int64(65), metrics["stats.uptime"], 6)
+}
+
+func mockSystemStats(sqlStore *mockstore.SQLStoreMock) {
+ sqlStore.ExpectedSystemStats = &models.SystemStats{
+ Dashboards: 1,
+ Datasources: 2,
+ Users: 3,
+ Admins: 31,
+ Editors: 32,
+ Viewers: 33,
+ ActiveUsers: 4,
+ ActiveAdmins: 21,
+ ActiveEditors: 22,
+ ActiveViewers: 23,
+ ActiveSessions: 24,
+ DailyActiveUsers: 25,
+ DailyActiveAdmins: 26,
+ DailyActiveEditors: 27,
+ DailyActiveViewers: 28,
+ DailyActiveSessions: 29,
+ Orgs: 5,
+ Playlists: 6,
+ Alerts: 7,
+ Stars: 8,
+ Folders: 9,
+ DashboardPermissions: 10,
+ FolderPermissions: 11,
+ ProvisionedDashboards: 12,
+ Snapshots: 13,
+ Teams: 14,
+ AuthTokens: 15,
+ DashboardVersions: 16,
+ Annotations: 17,
+ AlertRules: 18,
+ LibraryPanels: 19,
+ LibraryVariables: 20,
+ DashboardsViewersCanAdmin: 3,
+ DashboardsViewersCanEdit: 2,
+ FoldersViewersCanAdmin: 1,
+ FoldersViewersCanEdit: 5,
+ APIKeys: 2,
+ }
+}
+
+type mockSocial struct {
+ social.Service
+
+ OAuthProviders map[string]bool
+}
+
+func (m *mockSocial) GetOAuthProviders() map[string]bool {
+ return m.OAuthProviders
+}
+
+type fakePluginStore struct {
+ plugins.Store
+
+ plugins map[string]plugins.PluginDTO
+}
+
+func (pr fakePluginStore) Plugin(_ context.Context, pluginID string) (plugins.PluginDTO, bool) {
+ p, exists := pr.plugins[pluginID]
+
+ return p, exists
+}
+
+func setupSomeDataSourcePlugins(t *testing.T, s *Service) {
+ t.Helper()
+
+ s.plugins = &fakePluginStore{
+ plugins: map[string]plugins.PluginDTO{
+ models.DS_ES: {
+ Signature: "internal",
+ },
+ models.DS_PROMETHEUS: {
+ Signature: "internal",
+ },
+ models.DS_GRAPHITE: {
+ Signature: "internal",
+ },
+ models.DS_MYSQL: {
+ Signature: "internal",
+ },
+ },
+ }
+}
+
+func (pr fakePluginStore) Plugins(_ context.Context, pluginTypes ...plugins.Type) []plugins.PluginDTO {
+ var result []plugins.PluginDTO
+ for _, v := range pr.plugins {
+ for _, t := range pluginTypes {
+ if v.Type == t {
+ result = append(result, v)
+ }
+ }
+ }
+
+ return result
+}
+
+func createService(t testing.TB, cfg *setting.Cfg, store sqlstore.Store) *Service {
+ t.Helper()
+
+ return ProvideService(
+ &usagestats.UsageStatsMock{},
+ cfg,
+ store,
+ &mockSocial{},
+ &fakePluginStore{},
+ featuremgmt.WithFeatures("feature1", "feature2"),
+ )
+}
diff --git a/pkg/login/auth.go b/pkg/login/auth.go
index ae76ee3bec5..d75b1cd8ad2 100644
--- a/pkg/login/auth.go
+++ b/pkg/login/auth.go
@@ -4,7 +4,6 @@ import (
"context"
"errors"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/ldap"
@@ -41,7 +40,6 @@ func ProvideService(store sqlstore.Store, loginService login.Service) *Authentic
store: store,
loginService: loginService,
}
- bus.AddHandler("auth", a.AuthenticateUser)
return a
}
diff --git a/pkg/middleware/middleware_basic_auth_test.go b/pkg/middleware/middleware_basic_auth_test.go
index 7588d2172e1..5836a0d315b 100644
--- a/pkg/middleware/middleware_basic_auth_test.go
+++ b/pkg/middleware/middleware_basic_auth_test.go
@@ -1,11 +1,9 @@
package middleware
import (
- "context"
"encoding/json"
"testing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/contexthandler"
@@ -42,22 +40,8 @@ func TestMiddlewareBasicAuth(t *testing.T) {
middlewareScenario(t, "Handle auth", func(t *testing.T, sc *scenarioContext) {
const password = "MyPass"
- const salt = "Salt"
const orgID int64 = 2
- bus.AddHandler("grafana-auth", func(ctx context.Context, query *models.LoginUserQuery) error {
- t.Log("Handling LoginUserQuery")
- encoded, err := util.EncodePassword(password, salt)
- if err != nil {
- return err
- }
- query.User = &models.User{
- Password: encoded,
- Salt: salt,
- }
- return nil
- })
-
sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{OrgId: orgID, UserId: id}
authHeader := util.GetBasicAuthHeader("myUser", password)
@@ -88,6 +72,7 @@ func TestMiddlewareBasicAuth(t *testing.T) {
}, configure)
middlewareScenario(t, "Should return error if user is not found", func(t *testing.T, sc *scenarioContext) {
+ sc.mockSQLStore.ExpectedError = models.ErrUserNotFound
sc.fakeReq("GET", "/")
sc.req.SetBasicAuth("user", "password")
sc.exec()
@@ -100,10 +85,7 @@ func TestMiddlewareBasicAuth(t *testing.T) {
}, configure)
middlewareScenario(t, "Should return error if user & password do not match", func(t *testing.T, sc *scenarioContext) {
- bus.AddHandler("user-query", func(ctx context.Context, loginUserQuery *models.GetUserByLoginQuery) error {
- return nil
- })
-
+ sc.mockSQLStore.ExpectedError = models.ErrUserNotFound
sc.fakeReq("GET", "/")
sc.req.SetBasicAuth("killa", "gorilla")
sc.exec()
diff --git a/pkg/middleware/middleware_jwt_auth_test.go b/pkg/middleware/middleware_jwt_auth_test.go
index 02d4075201d..31403464d69 100644
--- a/pkg/middleware/middleware_jwt_auth_test.go
+++ b/pkg/middleware/middleware_jwt_auth_test.go
@@ -7,7 +7,6 @@ import (
"github.com/stretchr/testify/assert"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/contexthandler"
"github.com/grafana/grafana/pkg/setting"
@@ -109,14 +108,6 @@ func TestMiddlewareJWTAuth(t *testing.T) {
}, nil
}
sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{UserId: id, OrgId: orgID, Email: myEmail}
- bus.AddHandler("upsert-user", func(ctx context.Context, command *models.UpsertUserCommand) error {
- command.Result = &models.User{
- Id: id,
- Name: command.ExternalUser.Name,
- Email: command.ExternalUser.Email,
- }
- return nil
- })
sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec()
assert.Equal(t, verifiedToken, token)
diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go
index fe645b87cd2..76dbd87b1be 100644
--- a/pkg/middleware/middleware_test.go
+++ b/pkg/middleware/middleware_test.go
@@ -14,7 +14,6 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
"github.com/grafana/grafana/pkg/api/dtos"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/fs"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/remotecache"
@@ -25,6 +24,7 @@ import (
"github.com/grafana/grafana/pkg/services/contexthandler"
"github.com/grafana/grafana/pkg/services/contexthandler/authproxy"
"github.com/grafana/grafana/pkg/services/login/loginservice"
+ "github.com/grafana/grafana/pkg/services/login/logintest"
"github.com/grafana/grafana/pkg/services/rendering"
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
"github.com/grafana/grafana/pkg/setting"
@@ -569,8 +569,6 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func(
t.Helper()
t.Run(desc, func(t *testing.T) {
- t.Cleanup(bus.ClearBusHandlers)
-
logger := log.New("test")
loginMaxLifetime, err := gtime.ParseDuration("30d")
@@ -602,7 +600,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func(
sc.sqlStore = ctxHdlr.SQLStore
sc.contextHandler = ctxHdlr
sc.m.Use(ctxHdlr.Middleware)
- sc.m.Use(OrgRedirect(sc.cfg))
+ sc.m.Use(OrgRedirect(sc.cfg, sc.mockSQLStore))
sc.userAuthTokenService = ctxHdlr.AuthTokenService.(*auth.FakeUserAuthTokenService)
sc.jwtAuthService = ctxHdlr.JWTAuthService.(*models.FakeJWTService)
@@ -644,8 +642,9 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg, mockSQLStore *mockstore.S
authJWTSvc := models.NewFakeJWTService()
tracer, err := tracing.InitializeTracerForTest()
authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginService, mockSQLStore)
+ authenticator := &logintest.AuthenticatorFake{ExpectedUser: &models.User{}}
require.NoError(t, err)
- return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy)
+ return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy, loginService, authenticator)
}
type fakeRenderService struct {
diff --git a/pkg/middleware/org_redirect.go b/pkg/middleware/org_redirect.go
index e6f8b638f52..ecf0d0800b0 100644
--- a/pkg/middleware/org_redirect.go
+++ b/pkg/middleware/org_redirect.go
@@ -6,16 +6,16 @@ import (
"strconv"
"strings"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/contexthandler"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
)
// OrgRedirect changes org and redirects users if the
// querystring `orgId` doesn't match the active org.
-func OrgRedirect(cfg *setting.Cfg) web.Handler {
+func OrgRedirect(cfg *setting.Cfg, store sqlstore.Store) web.Handler {
return func(res http.ResponseWriter, req *http.Request, c *web.Context) {
orgIdValue := req.URL.Query().Get("orgId")
orgId, err := strconv.ParseInt(orgIdValue, 10, 64)
@@ -34,7 +34,7 @@ func OrgRedirect(cfg *setting.Cfg) web.Handler {
}
cmd := models.SetUsingOrgCommand{UserId: ctx.UserId, OrgId: orgId}
- if err := bus.Dispatch(ctx.Req.Context(), &cmd); err != nil {
+ if err := store.SetUsingOrg(ctx.Req.Context(), &cmd); err != nil {
if ctx.IsApiRequest() {
ctx.JsonApiErr(404, "Not found", nil)
} else {
diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go
index 5917e9a5377..f943d12bd0a 100644
--- a/pkg/middleware/org_redirect_test.go
+++ b/pkg/middleware/org_redirect_test.go
@@ -5,7 +5,6 @@ import (
"fmt"
"testing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/stretchr/testify/require"
)
@@ -47,10 +46,6 @@ func TestOrgRedirectMiddleware(t *testing.T) {
middlewareScenario(t, tc.desc, func(t *testing.T, sc *scenarioContext) {
sc.withTokenSessionCookie("token")
sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{OrgId: 1, UserId: 12}
- bus.AddHandler("test", func(ctx context.Context, query *models.SetUsingOrgCommand) error {
- return nil
- })
-
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) {
return &models.UserToken{
UserId: 0,
@@ -68,9 +63,7 @@ func TestOrgRedirectMiddleware(t *testing.T) {
middlewareScenario(t, "when setting an invalid org for user", func(t *testing.T, sc *scenarioContext) {
sc.withTokenSessionCookie("token")
- bus.AddHandler("test", func(ctx context.Context, query *models.SetUsingOrgCommand) error {
- return fmt.Errorf("")
- })
+ sc.mockSQLStore.ExpectedSetUsingOrgError = fmt.Errorf("")
sc.mockSQLStore.ExpectedSignedInUser = &models.SignedInUser{OrgId: 1, UserId: 12}
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) {
diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go
index 51b1c6c1c4d..251558056dc 100644
--- a/pkg/middleware/recovery_test.go
+++ b/pkg/middleware/recovery_test.go
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth"
@@ -48,8 +47,6 @@ func panicHandler(c *models.ReqContext) {
func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) {
t.Run(desc, func(t *testing.T) {
- defer bus.ClearBusHandlers()
-
cfg := setting.NewCfg()
cfg.ErrTemplateName = "error-template"
sc := &scenarioContext{
@@ -73,7 +70,7 @@ func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) {
contextHandler := getContextHandler(t, nil, nil, nil)
sc.m.Use(contextHandler.Middleware)
// mock out gc goroutine
- sc.m.Use(OrgRedirect(cfg))
+ sc.m.Use(OrgRedirect(cfg, sc.mockSQLStore))
sc.defaultHandler = func(c *models.ReqContext) {
sc.context = c
diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go
index 3a74f718648..cf6eec40fac 100644
--- a/pkg/plugins/manager/manager_integration_test.go
+++ b/pkg/plugins/manager/manager_integration_test.go
@@ -127,7 +127,6 @@ func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) {
"news": {},
"nodeGraph": {},
"piechart": {},
- "pluginlist": {},
"stat": {},
"state-timeline": {},
"status-history": {},
diff --git a/pkg/schema/load/load_test.go b/pkg/schema/load/load_test.go
index e7d3c1e310c..65d7655bac5 100644
--- a/pkg/schema/load/load_test.go
+++ b/pkg/schema/load/load_test.go
@@ -36,7 +36,6 @@ type testfunc func(*testing.T, schema.VersionedCueSchema, []byte, fs.FileInfo, s
// TODO: remove validdir once we can test directly with devenv folder
var doTestAgainstDevenv = func(sch schema.VersionedCueSchema, validdir string, fn testfunc) func(t *testing.T) {
return func(t *testing.T) {
- t.Parallel()
require.NoError(t, filepath.Walk(validdir, func(path string, d fs.FileInfo, err error) error {
require.NoError(t, err)
diff --git a/pkg/schema/testdata/devenvgoldenfiles/all-panels.json b/pkg/schema/testdata/devenvgoldenfiles/all-panels.json
index 305b3608691..c86ce253881 100644
--- a/pkg/schema/testdata/devenvgoldenfiles/all-panels.json
+++ b/pkg/schema/testdata/devenvgoldenfiles/all-panels.json
@@ -444,27 +444,6 @@
"overrides": []
}
},
- {
- "gridPos": {
- "h": 8,
- "w": 12,
- "x": 0,
- "y": 29
- },
- "id": 30,
- "title": "Panel list",
- "type": "pluginlist",
- "transparent": false,
- "repeatDirection": "h",
- "transformations": [],
- "options": {},
- "fieldConfig": {
- "defaults": {
- "custom": {}
- },
- "overrides": []
- }
- },
{
"gridPos": {
"h": 8,
diff --git a/pkg/server/backgroundsvcs/background_services.go b/pkg/server/backgroundsvcs/background_services.go
index 8521761435e..529e7abde09 100644
--- a/pkg/server/backgroundsvcs/background_services.go
+++ b/pkg/server/backgroundsvcs/background_services.go
@@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/infra/tracing"
uss "github.com/grafana/grafana/pkg/infra/usagestats/service"
+ "github.com/grafana/grafana/pkg/infra/usagestats/statscollector"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins/manager"
"github.com/grafana/grafana/pkg/registry"
@@ -32,9 +33,10 @@ func ProvideBackgroundServiceRegistry(
pushGateway *pushhttp.Gateway, notifications *notifications.NotificationService, pm *manager.PluginManager,
rendering *rendering.RenderingService, tokenService models.UserTokenBackgroundService, tracing tracing.Tracer,
provisioning *provisioning.ProvisioningServiceImpl, alerting *alerting.AlertEngine, usageStats *uss.UsageStats,
- grafanaUpdateChecker *updatechecker.GrafanaService, pluginsUpdateChecker *updatechecker.PluginsService,
- metrics *metrics.InternalMetricsService, secretsService *secretsManager.SecretsService,
- remoteCache *remotecache.RemoteCache, thumbnailsService thumbs.Service, StorageService store.StorageService,
+ statsCollector *statscollector.Service, grafanaUpdateChecker *updatechecker.GrafanaService,
+ pluginsUpdateChecker *updatechecker.PluginsService, metrics *metrics.InternalMetricsService,
+ secretsService *secretsManager.SecretsService, remoteCache *remotecache.RemoteCache,
+ thumbnailsService thumbs.Service, StorageService store.StorageService,
// Need to make sure these are initialized, is there a better place to put them?
_ *dashboardsnapshots.Service, _ *alerting.AlertNotificationService,
_ serviceaccounts.Service, _ *guardian.Provider,
@@ -56,11 +58,13 @@ func ProvideBackgroundServiceRegistry(
pluginsUpdateChecker,
metrics,
usageStats,
+ statsCollector,
tracing,
remoteCache,
secretsService,
StorageService,
- thumbnailsService)
+ thumbnailsService,
+ )
}
// BackgroundServiceRegistry provides background services.
diff --git a/pkg/server/wire.go b/pkg/server/wire.go
index ab9210af8ac..0e145c18762 100644
--- a/pkg/server/wire.go
+++ b/pkg/server/wire.go
@@ -5,6 +5,7 @@ package server
import (
"github.com/google/wire"
+
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana/pkg/api"
"github.com/grafana/grafana/pkg/api/avatar"
@@ -21,6 +22,7 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/infra/usagestats"
uss "github.com/grafana/grafana/pkg/infra/usagestats/service"
+ "github.com/grafana/grafana/pkg/infra/usagestats/statscollector"
loginpkg "github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/models"
@@ -237,6 +239,7 @@ var wireBasicSet = wire.NewSet(
secretsStore.ProvideService,
avatar.ProvideAvatarCacheServer,
authproxy.ProvideAuthProxy,
+ statscollector.ProvideService,
)
var wireSet = wire.NewSet(
diff --git a/pkg/services/alerting/engine.go b/pkg/services/alerting/engine.go
index cb39ff67448..9566d7725b4 100644
--- a/pkg/services/alerting/engine.go
+++ b/pkg/services/alerting/engine.go
@@ -30,6 +30,7 @@ type AlertStore interface {
GetDashboardUIDById(context.Context, *models.GetDashboardRefByIdQuery) error
SetAlertNotificationStateToCompleteCommand(context.Context, *models.SetAlertNotificationStateToCompleteCommand) error
SetAlertNotificationStateToPendingCommand(context.Context, *models.SetAlertNotificationStateToPendingCommand) error
+ GetAlertNotificationUidWithId(context.Context, *models.GetAlertNotificationUidQuery) error
GetAlertNotificationsWithUidToSend(context.Context, *models.GetAlertNotificationsWithUidToSendQuery) error
GetOrCreateAlertNotificationState(context.Context, *models.GetOrCreateNotificationStateQuery) error
SetAlertState(context.Context, *models.SetAlertStateCommand) error
diff --git a/pkg/services/alerting/engine_test.go b/pkg/services/alerting/engine_test.go
index 6e7fb674d68..3ed3ee42362 100644
--- a/pkg/services/alerting/engine_test.go
+++ b/pkg/services/alerting/engine_test.go
@@ -65,6 +65,10 @@ func (a *AlertStoreMock) GetAllAlertQueryHandler(c context.Context, cmd *models.
return nil
}
+func (a *AlertStoreMock) GetAlertNotificationUidWithId(c context.Context, query *models.GetAlertNotificationUidQuery) error {
+ return nil
+}
+
func (a *AlertStoreMock) GetAlertNotificationsWithUidToSend(c context.Context, cmd *models.GetAlertNotificationsWithUidToSendQuery) error {
if a.getAlertNotificationsWithUidToSend != nil {
return a.getAlertNotificationsWithUidToSend(c, cmd)
diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go
index 3238b5b60ac..460489b0a3d 100644
--- a/pkg/services/alerting/extractor.go
+++ b/pkg/services/alerting/extractor.go
@@ -22,13 +22,15 @@ type DashAlertExtractor interface {
type DashAlertExtractorService struct {
datasourcePermissionsService permissions.DatasourcePermissionsService
datasourceService datasources.DataSourceService
+ alertStore AlertStore
log log.Logger
}
-func ProvideDashAlertExtractorService(datasourcePermissionsService permissions.DatasourcePermissionsService, datasourceService datasources.DataSourceService) *DashAlertExtractorService {
+func ProvideDashAlertExtractorService(datasourcePermissionsService permissions.DatasourcePermissionsService, datasourceService datasources.DataSourceService, alertStore AlertStore) *DashAlertExtractorService {
return &DashAlertExtractorService{
datasourcePermissionsService: datasourcePermissionsService,
datasourceService: datasourceService,
+ alertStore: alertStore,
log: log.New("alerting.extractor"),
}
}
@@ -231,7 +233,7 @@ func (e *DashAlertExtractorService) getAlertFromPanels(ctx context.Context, json
alert.Settings = jsonAlert
// validate
- _, err = NewRuleFromDBAlert(ctx, alert, logTranslationFailures)
+ _, err = NewRuleFromDBAlert(ctx, e.alertStore, alert, logTranslationFailures)
if err != nil {
return nil, err
}
diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go
index 90c7499d9e3..9b2dba8b0fc 100644
--- a/pkg/services/alerting/extractor_test.go
+++ b/pkg/services/alerting/extractor_test.go
@@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/datasources/permissions"
"github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -36,7 +37,8 @@ func TestAlertRuleExtraction(t *testing.T) {
}
dsService := &fakeDatasourceService{ExpectedDatasource: defaultDs}
- extractor := ProvideDashAlertExtractorService(dsPermissions, dsService)
+ store := mockstore.NewSQLStoreMock()
+ extractor := ProvideDashAlertExtractorService(dsPermissions, dsService, store)
t.Run("Parsing alert rules from dashboard json", func(t *testing.T) {
dashJSON, err := simplejson.NewJson(json)
@@ -312,7 +314,7 @@ func TestFilterPermissionsErrors(t *testing.T) {
dsPermissions := permissions.NewMockDatasourcePermissionService()
dsService := &fakeDatasourceService{ExpectedDatasource: defaultDs}
- extractor := ProvideDashAlertExtractorService(dsPermissions, dsService)
+ extractor := ProvideDashAlertExtractorService(dsPermissions, dsService, nil)
tc := []struct {
name string
diff --git a/pkg/services/alerting/notifier_test.go b/pkg/services/alerting/notifier_test.go
index 558697083c8..c921908c6b8 100644
--- a/pkg/services/alerting/notifier_test.go
+++ b/pkg/services/alerting/notifier_test.go
@@ -13,7 +13,6 @@ import (
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
- "github.com/grafana/grafana/pkg/bus"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/components/imguploader"
@@ -205,14 +204,6 @@ func notificationServiceScenario(t *testing.T, name string, evalCtx *EvalContext
return nil
}
- bus.AddHandler("test", func(ctx context.Context, cmd *models.SetAlertNotificationStateToPendingCommand) error {
- return nil
- })
-
- bus.AddHandler("test", func(ctx context.Context, cmd *models.SetAlertNotificationStateToCompleteCommand) error {
- return nil
- })
-
setting.AlertingNotificationTimeout = 30 * time.Second
scenarioCtx := &scenarioContext{
diff --git a/pkg/services/alerting/reader.go b/pkg/services/alerting/reader.go
index 6d4cc8dd80f..ebd41626164 100644
--- a/pkg/services/alerting/reader.go
+++ b/pkg/services/alerting/reader.go
@@ -38,7 +38,7 @@ func (arr *defaultRuleReader) fetch(ctx context.Context) []*Rule {
res := make([]*Rule, 0)
for _, ruleDef := range cmd.Result {
- if model, err := NewRuleFromDBAlert(ctx, ruleDef, false); err != nil {
+ if model, err := NewRuleFromDBAlert(ctx, arr.sqlStore, ruleDef, false); err != nil {
arr.log.Error("Could not build alert model for rule", "ruleId", ruleDef.Id, "error", err)
} else {
res = append(res, model)
diff --git a/pkg/services/alerting/rule.go b/pkg/services/alerting/rule.go
index 05ffc47805d..7dd920d2bfa 100644
--- a/pkg/services/alerting/rule.go
+++ b/pkg/services/alerting/rule.go
@@ -9,7 +9,6 @@ import (
"strconv"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
)
@@ -145,7 +144,7 @@ func getForValue(rawFor string) (time.Duration, error) {
// NewRuleFromDBAlert maps a db version of
// alert to an in-memory version.
-func NewRuleFromDBAlert(ctx context.Context, ruleDef *models.Alert, logTranslationFailures bool) (*Rule, error) {
+func NewRuleFromDBAlert(ctx context.Context, store AlertStore, ruleDef *models.Alert, logTranslationFailures bool) (*Rule, error) {
model := &Rule{}
model.ID = ruleDef.Id
model.OrgID = ruleDef.OrgId
@@ -170,7 +169,7 @@ func NewRuleFromDBAlert(ctx context.Context, ruleDef *models.Alert, logTranslati
for _, v := range ruleDef.Settings.Get("notifications").MustArray() {
jsonModel := simplejson.NewFromAny(v)
if id, err := jsonModel.Get("id").Int64(); err == nil {
- uid, err := translateNotificationIDToUID(ctx, id, ruleDef.OrgId)
+ uid, err := translateNotificationIDToUID(ctx, store, id, ruleDef.OrgId)
if err != nil {
if !errors.Is(err, models.ErrAlertNotificationFailedTranslateUniqueID) {
logger.Error("Failed to translate notification id to uid", "error", err.Error(), "dashboardId", model.DashboardID, "alert", model.Name, "panelId", model.PanelID, "notificationId", id)
@@ -211,8 +210,8 @@ func NewRuleFromDBAlert(ctx context.Context, ruleDef *models.Alert, logTranslati
return model, nil
}
-func translateNotificationIDToUID(ctx context.Context, id int64, orgID int64) (string, error) {
- notificationUID, err := getAlertNotificationUIDByIDAndOrgID(ctx, id, orgID)
+func translateNotificationIDToUID(ctx context.Context, store AlertStore, id int64, orgID int64) (string, error) {
+ notificationUID, err := getAlertNotificationUIDByIDAndOrgID(ctx, store, id, orgID)
if err != nil {
return "", err
}
@@ -220,13 +219,13 @@ func translateNotificationIDToUID(ctx context.Context, id int64, orgID int64) (s
return notificationUID, nil
}
-func getAlertNotificationUIDByIDAndOrgID(ctx context.Context, notificationID int64, orgID int64) (string, error) {
+func getAlertNotificationUIDByIDAndOrgID(ctx context.Context, store AlertStore, notificationID int64, orgID int64) (string, error) {
query := &models.GetAlertNotificationUidQuery{
OrgId: orgID,
Id: notificationID,
}
- if err := bus.Dispatch(ctx, query); err != nil {
+ if err := store.GetAlertNotificationUidWithId(ctx, query); err != nil {
return "", err
}
diff --git a/pkg/services/alerting/rule_test.go b/pkg/services/alerting/rule_test.go
index 55c603a4c2b..506b7cb03b6 100644
--- a/pkg/services/alerting/rule_test.go
+++ b/pkg/services/alerting/rule_test.go
@@ -130,7 +130,7 @@ func TestAlertRuleModel(t *testing.T) {
Settings: alertJSON,
}
- alertRule, err := NewRuleFromDBAlert(context.Background(), alert, false)
+ alertRule, err := NewRuleFromDBAlert(context.Background(), sqlStore, alert, false)
require.Nil(t, err)
require.Len(t, alertRule.Conditions, 1)
@@ -169,7 +169,7 @@ func TestAlertRuleModel(t *testing.T) {
Settings: alertJSON,
}
- alertRule, err := NewRuleFromDBAlert(context.Background(), alert, false)
+ alertRule, err := NewRuleFromDBAlert(context.Background(), sqlStore, alert, false)
require.Nil(t, err)
require.NotContains(t, alertRule.Notifications, "999")
require.Contains(t, alertRule.Notifications, "notifier2")
@@ -200,7 +200,7 @@ func TestAlertRuleModel(t *testing.T) {
Settings: alertJSON,
}
- alertRule, err := NewRuleFromDBAlert(context.Background(), alert, false)
+ alertRule, err := NewRuleFromDBAlert(context.Background(), sqlStore, alert, false)
require.Nil(t, err)
require.EqualValues(t, alertRule.Frequency, 60)
})
@@ -238,7 +238,7 @@ func TestAlertRuleModel(t *testing.T) {
Settings: alertJSON,
}
- _, err := NewRuleFromDBAlert(context.Background(), alert, false)
+ _, err := NewRuleFromDBAlert(context.Background(), sqlStore, alert, false)
require.NotNil(t, err)
require.EqualValues(t, err.Error(), "alert validation error: Neither id nor uid is specified in 'notifications' block, type assertion to string failed AlertId: 1 PanelId: 1 DashboardId: 1")
})
diff --git a/pkg/services/alerting/service.go b/pkg/services/alerting/service.go
index 5031b4cb9cc..8db32e18728 100644
--- a/pkg/services/alerting/service.go
+++ b/pkg/services/alerting/service.go
@@ -28,20 +28,6 @@ func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, encryptionService enc
NotificationService: notificationService,
}
- s.Bus.AddHandler(s.GetAlertNotifications)
- s.Bus.AddHandler(s.CreateAlertNotificationCommand)
- s.Bus.AddHandler(s.UpdateAlertNotification)
- s.Bus.AddHandler(s.DeleteAlertNotification)
- s.Bus.AddHandler(s.GetAllAlertNotifications)
- s.Bus.AddHandler(s.GetOrCreateAlertNotificationState)
- s.Bus.AddHandler(s.SetAlertNotificationStateToCompleteCommand)
- s.Bus.AddHandler(s.SetAlertNotificationStateToPendingCommand)
- s.Bus.AddHandler(s.GetAlertNotificationsWithUid)
- s.Bus.AddHandler(s.UpdateAlertNotificationWithUid)
- s.Bus.AddHandler(s.DeleteAlertNotificationWithUid)
- s.Bus.AddHandler(s.GetAlertNotificationsWithUidToSend)
- s.Bus.AddHandler(s.HandleNotificationTestCommand)
-
return s
}
diff --git a/pkg/services/alerting/test_rule.go b/pkg/services/alerting/test_rule.go
index b8c4e2e5edf..011990faad7 100644
--- a/pkg/services/alerting/test_rule.go
+++ b/pkg/services/alerting/test_rule.go
@@ -25,7 +25,7 @@ func (e *AlertEngine) AlertTest(orgID int64, dashboard *simplejson.Json, panelID
if alert.PanelId != panelID {
continue
}
- rule, err := NewRuleFromDBAlert(context.Background(), alert, true)
+ rule, err := NewRuleFromDBAlert(context.Background(), e.sqlStore, alert, true)
if err != nil {
return nil, err
}
diff --git a/pkg/services/contexthandler/auth_jwt.go b/pkg/services/contexthandler/auth_jwt.go
index d29b4b07d65..cda763f2c37 100644
--- a/pkg/services/contexthandler/auth_jwt.go
+++ b/pkg/services/contexthandler/auth_jwt.go
@@ -3,7 +3,6 @@ package contexthandler
import (
"errors"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/models"
)
@@ -67,7 +66,7 @@ func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64)
SignupAllowed: h.Cfg.JWTAuthAutoSignUp,
ExternalUser: extUser,
}
- if err := bus.Dispatch(ctx.Req.Context(), upsert); err != nil {
+ if err := h.loginService.UpsertUser(ctx.Req.Context(), upsert); err != nil {
ctx.Logger.Error("Failed to upsert JWT user", "error", err)
return false
}
diff --git a/pkg/services/contexthandler/auth_proxy_test.go b/pkg/services/contexthandler/auth_proxy_test.go
index 82e6245c428..48fea6227bb 100644
--- a/pkg/services/contexthandler/auth_proxy_test.go
+++ b/pkg/services/contexthandler/auth_proxy_test.go
@@ -84,8 +84,9 @@ func getContextHandler(t *testing.T) *ContextHandler {
loginService := loginservice.LoginServiceMock{ExpectedUser: &models.User{Id: userID}}
authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginService, &FakeGetSignUserStore{})
+ authenticator := &fakeAuthenticator{}
- return ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy)
+ return ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, authenticator)
}
type FakeGetSignUserStore struct {
@@ -103,3 +104,9 @@ func (f *FakeGetSignUserStore) GetSignedInUser(ctx context.Context, query *model
}
return nil
}
+
+type fakeAuthenticator struct{}
+
+func (fa *fakeAuthenticator) AuthenticateUser(c context.Context, query *models.LoginUserQuery) error {
+ return nil
+}
diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go
index 99111ac9194..407b89fa326 100644
--- a/pkg/services/contexthandler/contexthandler.go
+++ b/pkg/services/contexthandler/contexthandler.go
@@ -9,12 +9,12 @@ import (
"strings"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/apikeygen"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/network"
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/infra/tracing"
+ loginpkg "github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/middleware/cookies"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/contexthandler/authproxy"
@@ -36,7 +36,7 @@ const ServiceName = "ContextHandler"
func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtService models.JWTService,
remoteCache *remotecache.RemoteCache, renderService rendering.Service, sqlStore sqlstore.Store,
- tracer tracing.Tracer, authProxy *authproxy.AuthProxy) *ContextHandler {
+ tracer tracing.Tracer, authProxy *authproxy.AuthProxy, loginService login.Service, authenticator loginpkg.Authenticator) *ContextHandler {
return &ContextHandler{
Cfg: cfg,
AuthTokenService: tokenService,
@@ -46,6 +46,8 @@ func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtS
SQLStore: sqlStore,
tracer: tracer,
authProxy: authProxy,
+ authenticator: authenticator,
+ loginService: loginService,
}
}
@@ -59,6 +61,8 @@ type ContextHandler struct {
SQLStore sqlstore.Store
tracer tracing.Tracer
authProxy *authproxy.AuthProxy
+ authenticator loginpkg.Authenticator
+ loginService login.Service
// GetTime returns the current time.
// Stubbable by tests.
GetTime func() time.Time
@@ -291,7 +295,7 @@ func (h *ContextHandler) initContextWithBasicAuth(reqContext *models.ReqContext,
Password: password,
Cfg: h.Cfg,
}
- if err := bus.Dispatch(reqContext.Req.Context(), &authQuery); err != nil {
+ if err := h.authenticator.AuthenticateUser(reqContext.Req.Context(), &authQuery); err != nil {
reqContext.Logger.Debug(
"Failed to authorize the user",
"username", username,
diff --git a/pkg/services/dashboards/manager/dashboard_service_test.go b/pkg/services/dashboards/manager/dashboard_service_test.go
index 7e303d70471..b1a11805674 100644
--- a/pkg/services/dashboards/manager/dashboard_service_test.go
+++ b/pkg/services/dashboards/manager/dashboard_service_test.go
@@ -11,7 +11,6 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
@@ -22,8 +21,6 @@ import (
func TestDashboardService(t *testing.T) {
t.Run("Dashboard service tests", func(t *testing.T) {
- bus.ClearBusHandlers()
-
fakeStore := m.FakeDashboardStore{}
defer fakeStore.AssertExpectations(t)
service := &DashboardServiceImpl{
diff --git a/pkg/services/dashboardsnapshots/dashboardsnapshots.go b/pkg/services/dashboardsnapshots/dashboardsnapshots.go
index 990e60bb818..88745a181ed 100644
--- a/pkg/services/dashboardsnapshots/dashboardsnapshots.go
+++ b/pkg/services/dashboardsnapshots/dashboardsnapshots.go
@@ -23,12 +23,6 @@ func ProvideService(bus bus.Bus, store sqlstore.Store, secretsService secrets.Se
SecretsService: secretsService,
}
- s.Bus.AddHandler(s.CreateDashboardSnapshot)
- s.Bus.AddHandler(s.GetDashboardSnapshot)
- s.Bus.AddHandler(s.DeleteDashboardSnapshot)
- s.Bus.AddHandler(s.SearchDashboardSnapshots)
- s.Bus.AddHandler(s.DeleteExpiredSnapshots)
-
return s
}
diff --git a/pkg/services/datasources/service/datasource_service.go b/pkg/services/datasources/service/datasource_service.go
index a0998ae8f86..623cd5ce048 100644
--- a/pkg/services/datasources/service/datasource_service.go
+++ b/pkg/services/datasources/service/datasource_service.go
@@ -66,14 +66,6 @@ func ProvideService(
permissionsService: permissionsServices.GetDataSourceService(),
}
- s.Bus.AddHandler(s.GetDataSources)
- s.Bus.AddHandler(s.GetDataSourcesByType)
- s.Bus.AddHandler(s.GetDataSource)
- s.Bus.AddHandler(s.AddDataSource)
- s.Bus.AddHandler(s.DeleteDataSource)
- s.Bus.AddHandler(s.UpdateDataSource)
- s.Bus.AddHandler(s.GetDefaultDataSource)
-
ac.RegisterAttributeScopeResolver(NewNameScopeResolver(store))
ac.RegisterAttributeScopeResolver(NewIDScopeResolver(store))
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index a55232ecbf3..60b46d46b6a 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -143,6 +143,12 @@ var (
State: FeatureStateAlpha,
RequiresDevMode: true,
},
+ {
+ Name: "publicDashboards",
+ Description: "enables public access to dashboards",
+ State: FeatureStateAlpha,
+ RequiresDevMode: true,
+ },
{
Name: "lokiLive",
Description: "support websocket streaming for loki (early prototype)",
@@ -201,5 +207,11 @@ var (
RequiresDevMode: true,
FrontendOnly: true,
},
+ {
+ Name: "explore2Dashboard",
+ Description: "Experimental Explore to Dashboard workflow",
+ State: FeatureStateBeta,
+ FrontendOnly: true,
+ },
}
)
diff --git a/pkg/services/featuremgmt/strcase/snake.go b/pkg/services/featuremgmt/strcase/snake.go
new file mode 100644
index 00000000000..012fdb181d2
--- /dev/null
+++ b/pkg/services/featuremgmt/strcase/snake.go
@@ -0,0 +1,118 @@
+// https://github.com/iancoleman/strcase/blob/master/snake.go
+
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2015 Ian Coleman
+ * Copyright (c) 2018 Ma_124,
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, Subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or Substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package strcase
+
+import (
+ "strings"
+)
+
+// ToSnake converts a string to snake_case
+func ToSnake(s string) string {
+ return ToDelimited(s, '_')
+}
+
+func ToSnakeWithIgnore(s string, ignore string) string {
+ return ToScreamingDelimited(s, '_', ignore, false)
+}
+
+// ToScreamingSnake converts a string to SCREAMING_SNAKE_CASE
+func ToScreamingSnake(s string) string {
+ return ToScreamingDelimited(s, '_', "", true)
+}
+
+// ToKebab converts a string to kebab-case
+func ToKebab(s string) string {
+ return ToDelimited(s, '-')
+}
+
+// ToScreamingKebab converts a string to SCREAMING-KEBAB-CASE
+func ToScreamingKebab(s string) string {
+ return ToScreamingDelimited(s, '-', "", true)
+}
+
+// ToDelimited converts a string to delimited.snake.case
+// (in this case `delimiter = '.'`)
+func ToDelimited(s string, delimiter uint8) string {
+ return ToScreamingDelimited(s, delimiter, "", false)
+}
+
+// ToScreamingDelimited converts a string to SCREAMING.DELIMITED.SNAKE.CASE
+// (in this case `delimiter = '.'; screaming = true`)
+// or delimited.snake.case
+// (in this case `delimiter = '.'; screaming = false`)
+//nolint: gocyclo
+func ToScreamingDelimited(s string, delimiter uint8, ignore string, screaming bool) string {
+ s = strings.TrimSpace(s)
+ n := strings.Builder{}
+ n.Grow(len(s) + 2) // nominal 2 bytes of extra space for inserted delimiters
+ for i, v := range []byte(s) {
+ vIsCap := v >= 'A' && v <= 'Z'
+ vIsLow := v >= 'a' && v <= 'z'
+ if vIsLow && screaming {
+ v += 'A'
+ v -= 'a'
+ } else if vIsCap && !screaming {
+ v += 'a'
+ v -= 'A'
+ }
+
+ // treat acronyms as words, eg for JSONData -> JSON is a whole word
+ if i+1 < len(s) {
+ next := s[i+1]
+ vIsNum := v >= '0' && v <= '9'
+ nextIsCap := next >= 'A' && next <= 'Z'
+ nextIsLow := next >= 'a' && next <= 'z'
+ nextIsNum := next >= '0' && next <= '9'
+ // add underscore if next letter case type is changed
+ if (vIsCap && (nextIsLow || nextIsNum)) || (vIsLow && (nextIsCap || nextIsNum)) || (vIsNum && (nextIsCap || nextIsLow)) {
+ prevIgnore := ignore != "" && i > 0 && strings.ContainsAny(string(s[i-1]), ignore)
+ if !prevIgnore {
+ if vIsCap && nextIsLow {
+ if prevIsCap := i > 0 && s[i-1] >= 'A' && s[i-1] <= 'Z'; prevIsCap {
+ n.WriteByte(delimiter)
+ }
+ }
+ n.WriteByte(v)
+ if vIsLow || vIsNum || nextIsNum {
+ n.WriteByte(delimiter)
+ }
+ continue
+ }
+ }
+ }
+
+ if (v == ' ' || v == '_' || v == '-' || v == '.') && !strings.ContainsAny(string(v), ignore) {
+ // replace space/underscore/hyphen/dot with delimiter
+ n.WriteByte(delimiter)
+ } else {
+ n.WriteByte(v)
+ }
+ }
+
+ return n.String()
+}
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index 06c470a7a72..7548ed76643 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -107,6 +107,10 @@ const (
// only execute the query saved in a panel
FlagValidatedQueries = "validatedQueries"
+ // FlagPublicDashboards
+ // enables public access to dashboards
+ FlagPublicDashboards = "publicDashboards"
+
// FlagLokiLive
// support websocket streaming for loki (early prototype)
FlagLokiLive = "lokiLive"
@@ -150,4 +154,8 @@ const (
// FlagAzureMonitorResourcePickerForMetrics
// New UI for Azure Monitor Metrics Query
FlagAzureMonitorResourcePickerForMetrics = "azureMonitorResourcePickerForMetrics"
+
+ // FlagExplore2Dashboard
+ // Experimental Explore to Dashboard workflow
+ FlagExplore2Dashboard = "explore2Dashboard"
)
diff --git a/pkg/services/featuremgmt/usage_stats.go b/pkg/services/featuremgmt/usage_stats.go
new file mode 100644
index 00000000000..f33e77834a6
--- /dev/null
+++ b/pkg/services/featuremgmt/usage_stats.go
@@ -0,0 +1,21 @@
+package featuremgmt
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/grafana/grafana/pkg/services/featuremgmt/strcase"
+)
+
+func (fm *FeatureManager) GetUsageStats(ctx context.Context) map[string]interface{} {
+ enabled := fm.GetEnabled(ctx)
+ stats := make(map[string]interface{}, len(enabled))
+ for featureName := range enabled {
+ stats[asMetricName(featureName)] = 1
+ }
+ return stats
+}
+
+func asMetricName(featureName string) string {
+ return fmt.Sprintf("stats.features.%s.count", strcase.ToSnake(featureName))
+}
diff --git a/pkg/services/featuremgmt/usage_stats_test.go b/pkg/services/featuremgmt/usage_stats_test.go
new file mode 100644
index 00000000000..1bc5420e4ff
--- /dev/null
+++ b/pkg/services/featuremgmt/usage_stats_test.go
@@ -0,0 +1,34 @@
+package featuremgmt
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestFeatureUsageStats(t *testing.T) {
+ featureManagerWithAllFeatures := WithFeatures(
+ "trimDefaults",
+ "httpclientprovider_azure_auth",
+ "service-accounts",
+ "database_metrics",
+ "dashboardPreviews",
+ "live-config",
+ "showFeatureFlagsInUI",
+ "UPPER_SNAKE_CASE",
+ "feature.with.a.dot",
+ )
+
+ require.Equal(t, map[string]interface{}{
+ "stats.features.trim_defaults.count": 1,
+ "stats.features.httpclientprovider_azure_auth.count": 1,
+ "stats.features.service_accounts.count": 1,
+ "stats.features.database_metrics.count": 1,
+ "stats.features.dashboard_previews.count": 1,
+ "stats.features.live_config.count": 1,
+ "stats.features.show_feature_flags_in_ui.count": 1,
+ "stats.features.upper_snake_case.count": 1,
+ "stats.features.feature_with_a_dot.count": 1,
+ }, featureManagerWithAllFeatures.GetUsageStats(context.Background()))
+}
diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go
index 2358b8f6342..a4bc6ed6cfa 100644
--- a/pkg/services/libraryelements/libraryelements_test.go
+++ b/pkg/services/libraryelements/libraryelements_test.go
@@ -201,7 +201,7 @@ func createDashboard(t *testing.T, sqlStore *sqlstore.SQLStore, user models.Sign
}
dashboardStore := database.ProvideDashboardStore(sqlStore)
- dashAlertExtractor := alerting.ProvideDashAlertExtractorService(nil, nil)
+ dashAlertExtractor := alerting.ProvideDashAlertExtractorService(nil, nil, nil)
service := dashboardservice.ProvideDashboardService(
setting.NewCfg(), dashboardStore, dashAlertExtractor,
featuremgmt.WithFeatures(), acmock.NewPermissionsServicesMock(),
diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go
index 0d13913c966..26f28a798c2 100644
--- a/pkg/services/librarypanels/librarypanels_test.go
+++ b/pkg/services/librarypanels/librarypanels_test.go
@@ -1422,7 +1422,7 @@ func createDashboard(t *testing.T, sqlStore *sqlstore.SQLStore, user *models.Sig
}
dashboardStore := database.ProvideDashboardStore(sqlStore)
- dashAlertService := alerting.ProvideDashAlertExtractorService(nil, nil)
+ dashAlertService := alerting.ProvideDashAlertExtractorService(nil, nil, nil)
service := dashboardservice.ProvideDashboardService(
setting.NewCfg(), dashboardStore, dashAlertService,
featuremgmt.WithFeatures(), acmock.NewPermissionsServicesMock(),
diff --git a/pkg/services/login/authinfoservice/database/database.go b/pkg/services/login/authinfoservice/database/database.go
index 5fa3ee4a89e..2ac7ad076e1 100644
--- a/pkg/services/login/authinfoservice/database/database.go
+++ b/pkg/services/login/authinfoservice/database/database.go
@@ -28,18 +28,9 @@ func ProvideAuthInfoStore(sqlStore sqlstore.Store, bus bus.Bus, secretsService s
secretsService: secretsService,
logger: log.New("login.authinfo.store"),
}
- store.registerBusHandlers()
return store
}
-func (s *AuthInfoStore) registerBusHandlers() {
- s.bus.AddHandler(s.GetExternalUserInfoByLogin)
- s.bus.AddHandler(s.GetAuthInfo)
- s.bus.AddHandler(s.SetAuthInfo)
- s.bus.AddHandler(s.UpdateAuthInfo)
- s.bus.AddHandler(s.DeleteAuthInfo)
-}
-
func (s *AuthInfoStore) GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error {
userQuery := models.GetUserByLoginQuery{LoginOrEmail: query.LoginOrEmail}
err := s.sqlStore.GetUserByLogin(ctx, &userQuery)
diff --git a/pkg/services/login/loginservice/loginservice.go b/pkg/services/login/loginservice/loginservice.go
index db5ccf002cb..a8f4f30837b 100644
--- a/pkg/services/login/loginservice/loginservice.go
+++ b/pkg/services/login/loginservice/loginservice.go
@@ -23,7 +23,6 @@ func ProvideService(sqlStore sqlstore.Store, bus bus.Bus, quotaService *quota.Qu
QuotaService: quotaService,
AuthInfoService: authInfoService,
}
- bus.AddHandler(s.UpsertUser)
return s
}
diff --git a/pkg/services/login/loginservice/loginservice_test.go b/pkg/services/login/loginservice/loginservice_test.go
index 4bace3556f9..924506e5845 100644
--- a/pkg/services/login/loginservice/loginservice_test.go
+++ b/pkg/services/login/loginservice/loginservice_test.go
@@ -80,8 +80,6 @@ func Test_teamSync(t *testing.T) {
Login: "test_user",
}
authInfoMock.ExpectedUser = expectedUser
- bus.ClearBusHandlers()
- t.Cleanup(func() { bus.ClearBusHandlers() })
var actualUser *models.User
var actualExternalUser *models.ExternalUserInfo
diff --git a/pkg/services/login/logintest/logintest.go b/pkg/services/login/logintest/logintest.go
index 6197e8d5ce9..020714632e8 100644
--- a/pkg/services/login/logintest/logintest.go
+++ b/pkg/services/login/logintest/logintest.go
@@ -49,3 +49,13 @@ func (a *AuthInfoServiceFake) GetExternalUserInfoByLogin(ctx context.Context, qu
query.Result = a.ExpectedExternalUser
return a.ExpectedError
}
+
+type AuthenticatorFake struct {
+ ExpectedUser *models.User
+ ExpectedError error
+}
+
+func (a *AuthenticatorFake) AuthenticateUser(c context.Context, query *models.LoginUserQuery) error {
+ query.User = a.ExpectedUser
+ return a.ExpectedError
+}
diff --git a/pkg/services/ngalert/CHANGELOG.md b/pkg/services/ngalert/CHANGELOG.md
new file mode 100644
index 00000000000..0b6fff8a661
--- /dev/null
+++ b/pkg/services/ngalert/CHANGELOG.md
@@ -0,0 +1,49 @@
+# Changelog
+
+**Please note that this is not a direct replacement of Grafana's "add to changelog" label. It is _mostly_ for internal consumption of the Alerting team that owns this part of Grafana.**
+
+## Scope Glossary
+
+### `[ADMIN]`
+The ADMIN scope denotes a change that affect the structure and layout of this repository. This includes updates to the following:
+
+- CODEOWNERS
+- README
+- DotFiles (.gitignore, .git-attributes, etc)
+
+Anything that a developer working on this repo should be aware of from a standards and practice perspective.
+
+### `[BUGFIX]`
+
+The BUGFIX scope denotes a change that fixes an issue with the project in question. A BUGFIX should align the behaviour of the service with the current expected behaviour of the service. If a BUGFIX introduces new unexpected behaviour to ameliorate the issue, a corresponding FEATURE or ENHANCEMENT scope should also be added to the changelog.
+
+### `[CHANGE]`
+
+The CHANGE scope denotes a change that changes the expected behavior of the project while not adding new functionality or fixing an underling issue. This commonly occurs when renaming things to make them more consistent or to accommodate updated versions of vendored dependencies.
+
+### `[FEATURE]`
+
+The FEATURE scope denotes a change that adds new functionality to the project/service.
+
+### `[ENHANCEMENT]`
+
+The ENHANCEMENT scope denotes a change that improves upon the current functionality of the project/service. Generally, an enhancement is something that improves upon something that is already present. Either by making it simpler, more powerful, or more performant. For Example:
+
+An optimization on a particular process in a service that makes it more performant
+Simpler syntax for setting a configuration value, like allowing 1m instead of 60 for a duration setting.
+
+## Order
+
+Scopes must have an order to ensure consistency and ease of search, this helps us identify which section do we need to look for what. The order must be:
+
+1. `[CHANGE]`
+2. `[FEATURE]`
+3. `[BUGFIX]`
+4. `[ENHANCEMENT]`
+5. `[ADMIN]`
+
+
+## Grafana Alerting - main / unreleased
+
+- [CHANGE] Prometheus Compatible API: Use float-like values for `api/prometheus/grafana/api/v1/alerts` and `api/prometheus/grafana/api/v1/rules` instead of the evaluation string #47216
+- [ENHANCEMENT] Templates: Enable the use of classic condition values in templates #46971
\ No newline at end of file
diff --git a/pkg/services/ngalert/notifier/alertmanager_test.go b/pkg/services/ngalert/notifier/alertmanager_test.go
index 94ec8a365fb..5e2f10e7420 100644
--- a/pkg/services/ngalert/notifier/alertmanager_test.go
+++ b/pkg/services/ngalert/notifier/alertmanager_test.go
@@ -386,10 +386,10 @@ func TestSilenceCleanup(t *testing.T) {
makeSilence("", "tests", dt(now.Add(5*time.Hour)), dt(now.Add(6*time.Hour)), matchers),
// Active now
makeSilence("", "tests", dt(now.Add(-5*time.Hour)), dt(now.Add(6*time.Hour)), matchers),
- // Expiring soon
- makeSilence("", "tests", dt(now.Add(-5*time.Hour)), dt(now.Add(2*time.Second)), matchers),
+ // Expiring soon.
+ makeSilence("", "tests", dt(now.Add(-5*time.Hour)), dt(now.Add(5*time.Second)), matchers),
// Expiring *very* soon
- makeSilence("", "tests", dt(now.Add(-5*time.Hour)), dt(now.Add(20*time.Millisecond)), matchers),
+ makeSilence("", "tests", dt(now.Add(-5*time.Hour)), dt(now.Add(2*time.Second)), matchers),
}
for _, s := range silences {
@@ -403,12 +403,12 @@ func TestSilenceCleanup(t *testing.T) {
found, err := am.ListSilences(nil)
require.NoError(err)
return len(found) == 3
- }, 1500*time.Millisecond, 150*time.Millisecond)
+ }, 3*time.Second, 150*time.Millisecond)
// Wait again for another silence to expire.
require.Eventually(func() bool {
found, err := am.ListSilences(nil)
require.NoError(err)
return len(found) == 2
- }, 2*time.Second, 150*time.Millisecond)
+ }, 6*time.Second, 150*time.Millisecond)
}
diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go
index 221b3223891..7c86ebb8348 100644
--- a/pkg/services/notifications/notifications.go
+++ b/pkg/services/notifications/notifications.go
@@ -45,13 +45,6 @@ func ProvideService(bus bus.Bus, cfg *setting.Cfg, mailer Mailer, store TempUser
store: store,
}
- ns.Bus.AddHandler(ns.SendResetPasswordEmail)
- ns.Bus.AddHandler(ns.ValidateResetPasswordCode)
- ns.Bus.AddHandler(ns.SendEmailCommandHandler)
-
- ns.Bus.AddHandler(ns.SendEmailCommandHandlerSync)
- ns.Bus.AddHandler(ns.SendWebhookSync)
-
ns.Bus.AddEventListener(ns.signUpStartedHandler)
ns.Bus.AddEventListener(ns.signUpCompletedHandler)
diff --git a/pkg/services/notifications/notifications_test.go b/pkg/services/notifications/notifications_test.go
index bd07a4dd291..0244c7df558 100644
--- a/pkg/services/notifications/notifications_test.go
+++ b/pkg/services/notifications/notifications_test.go
@@ -35,7 +35,7 @@ func TestSendEmailSync(t *testing.T) {
bus := bus.New()
t.Run("When sending emails synchronously", func(t *testing.T) {
- _, mailer := createSut(t, bus)
+ ns, mailer := createSut(t, bus)
cmd := &models.SendEmailCommandSync{
SendEmailCommand: models.SendEmailCommand{
Subject: "subject",
@@ -44,8 +44,7 @@ func TestSendEmailSync(t *testing.T) {
Template: "welcome_on_signup",
},
}
-
- err := bus.Dispatch(context.Background(), cmd)
+ err := ns.SendEmailCommandHandlerSync(context.Background(), cmd)
require.NoError(t, err)
require.NotEmpty(t, mailer.Sent)
@@ -55,7 +54,7 @@ func TestSendEmailSync(t *testing.T) {
})
t.Run("When using Single Email mode with multiple recipients", func(t *testing.T) {
- _, mailer := createSut(t, bus)
+ ns, mailer := createSut(t, bus)
cmd := &models.SendEmailCommandSync{
SendEmailCommand: models.SendEmailCommand{
Subject: "subject",
@@ -65,14 +64,14 @@ func TestSendEmailSync(t *testing.T) {
},
}
- err := bus.Dispatch(context.Background(), cmd)
+ err := ns.SendEmailCommandHandlerSync(context.Background(), cmd)
require.NoError(t, err)
require.Len(t, mailer.Sent, 1)
})
t.Run("When using Multi Email mode with multiple recipients", func(t *testing.T) {
- _, mailer := createSut(t, bus)
+ ns, mailer := createSut(t, bus)
cmd := &models.SendEmailCommandSync{
SendEmailCommand: models.SendEmailCommand{
Subject: "subject",
@@ -82,14 +81,14 @@ func TestSendEmailSync(t *testing.T) {
},
}
- err := bus.Dispatch(context.Background(), cmd)
+ err := ns.SendEmailCommandHandlerSync(context.Background(), cmd)
require.NoError(t, err)
require.Len(t, mailer.Sent, 3)
})
t.Run("When attaching files to emails", func(t *testing.T) {
- _, mailer := createSut(t, bus)
+ ns, mailer := createSut(t, bus)
cmd := &models.SendEmailCommandSync{
SendEmailCommand: models.SendEmailCommand{
Subject: "subject",
@@ -105,7 +104,7 @@ func TestSendEmailSync(t *testing.T) {
},
}
- err := bus.Dispatch(context.Background(), cmd)
+ err := ns.SendEmailCommandHandlerSync(context.Background(), cmd)
require.NoError(t, err)
require.NotEmpty(t, mailer.Sent)
@@ -119,7 +118,7 @@ func TestSendEmailSync(t *testing.T) {
t.Run("When SMTP disabled in configuration", func(t *testing.T) {
cfg := createSmtpConfig()
cfg.Smtp.Enabled = false
- _, mailer, err := createSutWithConfig(t, bus, cfg)
+ ns, mailer, err := createSutWithConfig(t, bus, cfg)
require.NoError(t, err)
cmd := &models.SendEmailCommandSync{
SendEmailCommand: models.SendEmailCommand{
@@ -130,7 +129,7 @@ func TestSendEmailSync(t *testing.T) {
},
}
- err = bus.Dispatch(context.Background(), cmd)
+ err = ns.SendEmailCommandHandlerSync(context.Background(), cmd)
require.ErrorIs(t, err, models.ErrSmtpNotEnabled)
require.Empty(t, mailer.Sent)
@@ -139,7 +138,7 @@ func TestSendEmailSync(t *testing.T) {
t.Run("When invalid content type in configuration", func(t *testing.T) {
cfg := createSmtpConfig()
cfg.Smtp.ContentTypes = append(cfg.Smtp.ContentTypes, "multipart/form-data")
- _, mailer, err := createSutWithConfig(t, bus, cfg)
+ ns, mailer, err := createSutWithConfig(t, bus, cfg)
require.NoError(t, err)
cmd := &models.SendEmailCommandSync{
SendEmailCommand: models.SendEmailCommand{
@@ -150,14 +149,14 @@ func TestSendEmailSync(t *testing.T) {
},
}
- err = bus.Dispatch(context.Background(), cmd)
+ err = ns.SendEmailCommandHandlerSync(context.Background(), cmd)
require.Error(t, err)
require.Empty(t, mailer.Sent)
})
t.Run("When SMTP dialer is disconnected", func(t *testing.T) {
- _ = createDisconnectedSut(t, bus)
+ ns := createDisconnectedSut(t, bus)
cmd := &models.SendEmailCommandSync{
SendEmailCommand: models.SendEmailCommand{
Subject: "subject",
@@ -167,7 +166,7 @@ func TestSendEmailSync(t *testing.T) {
},
}
- err := bus.Dispatch(context.Background(), cmd)
+ err := ns.SendEmailCommandHandlerSync(context.Background(), cmd)
require.Error(t, err)
})
@@ -210,7 +209,7 @@ func TestSendEmailAsync(t *testing.T) {
t.Run("When invalid content type in configuration", func(t *testing.T) {
cfg := createSmtpConfig()
cfg.Smtp.ContentTypes = append(cfg.Smtp.ContentTypes, "multipart/form-data")
- _, mailer, err := createSutWithConfig(t, bus, cfg)
+ ns, mailer, err := createSutWithConfig(t, bus, cfg)
require.NoError(t, err)
cmd := &models.SendEmailCommand{
Subject: "subject",
@@ -219,14 +218,14 @@ func TestSendEmailAsync(t *testing.T) {
Template: "welcome_on_signup",
}
- err = bus.Dispatch(context.Background(), cmd)
+ err = ns.SendEmailCommandHandler(context.Background(), cmd)
require.Error(t, err)
require.Empty(t, mailer.Sent)
})
t.Run("When SMTP dialer is disconnected", func(t *testing.T) {
- _ = createDisconnectedSut(t, bus)
+ ns := createDisconnectedSut(t, bus)
cmd := &models.SendEmailCommand{
Subject: "subject",
To: []string{"1@grafana.com", "2@grafana.com", "3@grafana.com"},
@@ -234,7 +233,7 @@ func TestSendEmailAsync(t *testing.T) {
Template: "welcome_on_signup",
}
- err := bus.Dispatch(context.Background(), cmd)
+ err := ns.SendEmailCommandHandler(context.Background(), cmd)
// The async version should not surface connection errors via Bus. It should only log them.
require.NoError(t, err)
diff --git a/pkg/services/plugindashboards/service/dashboard_updater_test.go b/pkg/services/plugindashboards/service/dashboard_updater_test.go
index 5648bb613be..05a0e33630e 100644
--- a/pkg/services/plugindashboards/service/dashboard_updater_test.go
+++ b/pkg/services/plugindashboards/service/dashboard_updater_test.go
@@ -569,8 +569,6 @@ func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenar
sCtx.dashboardService,
)
- t.Cleanup(bus.ClearBusHandlers)
-
t.Run(desc, func(t *testing.T) {
f(sCtx)
})
diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go
index 60752f263ac..881af5dd2cf 100644
--- a/pkg/services/provisioning/dashboards/dashboard.go
+++ b/pkg/services/provisioning/dashboards/dashboard.go
@@ -23,7 +23,7 @@ type DashboardProvisioner interface {
}
// DashboardProvisionerFactory creates DashboardProvisioners based on input
-type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, utils.OrgStore) (DashboardProvisioner, error)
+type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, utils.OrgStore, utils.DashboardStore) (DashboardProvisioner, error)
// Provisioner is responsible for syncing dashboard from disk to Grafana's database.
type Provisioner struct {
@@ -35,7 +35,7 @@ type Provisioner struct {
}
// New returns a new DashboardProvisioner
-func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgStore utils.OrgStore) (DashboardProvisioner, error) {
+func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgStore utils.OrgStore, dashboardStore utils.DashboardStore) (DashboardProvisioner, error) {
logger := log.New("provisioning.dashboard")
cfgReader := &configReader{path: configDirectory, log: logger, orgStore: orgStore}
configs, err := cfgReader.readConfig(ctx)
@@ -43,7 +43,7 @@ func New(ctx context.Context, configDirectory string, provisioner dashboards.Das
return nil, errutil.Wrap("Failed to read dashboards config", err)
}
- fileReaders, err := getFileReaders(configs, logger, provisioner)
+ fileReaders, err := getFileReaders(configs, logger, provisioner, dashboardStore)
if err != nil {
return nil, errutil.Wrap("Failed to initialize file readers", err)
}
@@ -123,14 +123,14 @@ func (provider *Provisioner) GetAllowUIUpdatesFromConfig(name string) bool {
}
func getFileReaders(
- configs []*config, logger log.Logger, service dashboards.DashboardProvisioningService,
+ configs []*config, logger log.Logger, service dashboards.DashboardProvisioningService, store utils.DashboardStore,
) ([]*FileReader, error) {
var readers []*FileReader
for _, config := range configs {
switch config.Type {
case "file":
- fileReader, err := NewDashboardFileReader(config, logger.New("type", config.Type, "name", config.Name), service)
+ fileReader, err := NewDashboardFileReader(config, logger.New("type", config.Type, "name", config.Name), service, store)
if err != nil {
return nil, errutil.Wrapf(err, "Failed to create file reader for config %v", config.Name)
}
diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go
index 36dfdf7c541..827a941a87a 100644
--- a/pkg/services/provisioning/dashboards/file_reader.go
+++ b/pkg/services/provisioning/dashboards/file_reader.go
@@ -11,12 +11,12 @@ import (
"sync"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/provisioning/utils"
"github.com/grafana/grafana/pkg/util"
)
@@ -33,6 +33,7 @@ type FileReader struct {
Path string
log log.Logger
dashboardProvisioningService dashboards.DashboardProvisioningService
+ dashboardStore utils.DashboardStore
FoldersFromFilesStructure bool
mux sync.RWMutex
@@ -41,7 +42,7 @@ type FileReader struct {
}
// NewDashboardFileReader returns a new filereader based on `config`
-func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.DashboardProvisioningService) (*FileReader, error) {
+func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.DashboardProvisioningService, dashboardStore utils.DashboardStore) (*FileReader, error) {
var path string
path, ok := cfg.Options["path"].(string)
if !ok {
@@ -63,6 +64,7 @@ func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.Dash
Path: path,
log: log,
dashboardProvisioningService: service,
+ dashboardStore: dashboardStore,
FoldersFromFilesStructure: foldersFromFilesStructure,
usageTracker: newUsageTracker(),
}, nil
@@ -298,7 +300,7 @@ func (fr *FileReader) getOrCreateFolderID(ctx context.Context, cfg *config, serv
}
cmd := &models.GetDashboardQuery{Slug: models.SlugifyTitle(folderName), OrgId: cfg.OrgID}
- err := bus.Dispatch(ctx, cmd)
+ err := fr.dashboardStore.GetDashboard(ctx, cmd)
if err != nil && !errors.Is(err, models.ErrDashboardNotFound) {
return 0, err
diff --git a/pkg/services/provisioning/dashboards/file_reader_linux_test.go b/pkg/services/provisioning/dashboards/file_reader_symlink_test.go
similarity index 91%
rename from pkg/services/provisioning/dashboards/file_reader_linux_test.go
rename to pkg/services/provisioning/dashboards/file_reader_symlink_test.go
index c8f288d52f4..91b356e251a 100644
--- a/pkg/services/provisioning/dashboards/file_reader_linux_test.go
+++ b/pkg/services/provisioning/dashboards/file_reader_symlink_test.go
@@ -1,5 +1,5 @@
-//go:build linux
-// +build linux
+//go:build linux || darwin
+// +build linux darwin
package dashboards
@@ -25,7 +25,7 @@ func TestProvisionedSymlinkedFolder(t *testing.T) {
Options: map[string]interface{}{"path": symlinkedFolder},
}
- reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil)
+ reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil)
if err != nil {
t.Error("expected err to be nil")
}
diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go
index 8e6b01f55d1..627ed6041b8 100644
--- a/pkg/services/provisioning/dashboards/file_reader_test.go
+++ b/pkg/services/provisioning/dashboards/file_reader_test.go
@@ -8,7 +8,6 @@ import (
"testing"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
@@ -42,7 +41,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
t.Run("using path parameter", func(t *testing.T) {
cfg := setup()
cfg.Options["path"] = defaultDashboards
- reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil)
+ reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil)
require.NoError(t, err)
require.NotEqual(t, reader.Path, "")
})
@@ -50,7 +49,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
t.Run("using folder as options", func(t *testing.T) {
cfg := setup()
cfg.Options["folder"] = defaultDashboards
- reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil)
+ reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil)
require.NoError(t, err)
require.NotEqual(t, reader.Path, "")
})
@@ -59,7 +58,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
cfg := setup()
cfg.Options["path"] = foldersFromFilesStructure
cfg.Options["foldersFromFilesStructure"] = true
- reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil)
+ reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil)
require.NoError(t, err)
require.NotEqual(t, reader.Path, "")
})
@@ -72,7 +71,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
}
cfg.Options["folder"] = fullPath
- reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil)
+ reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil)
require.NoError(t, err)
require.Equal(t, reader.Path, fullPath)
@@ -82,7 +81,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
t.Run("using relative path", func(t *testing.T) {
cfg := setup()
cfg.Options["folder"] = defaultDashboards
- reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil)
+ reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil)
require.NoError(t, err)
resolvedPath := reader.resolvedPath()
@@ -96,9 +95,8 @@ func TestDashboardFileReader(t *testing.T) {
fakeService := &dashboards.FakeDashboardProvisioning{}
defer fakeService.AssertExpectations(t)
+ fakeStore := &fakeDashboardStore{}
setup := func() {
- bus.ClearBusHandlers()
- bus.AddHandler("test", mockGetDashboardQuery)
cfg = &config{
Name: configName,
Type: "file",
@@ -118,7 +116,7 @@ func TestDashboardFileReader(t *testing.T) {
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{Id: 1}, nil).Once()
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{Id: 2}, nil).Times(2)
- reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -138,7 +136,7 @@ func TestDashboardFileReader(t *testing.T) {
inserted++
})
- reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -175,7 +173,7 @@ func TestDashboardFileReader(t *testing.T) {
fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once()
- reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -203,7 +201,7 @@ func TestDashboardFileReader(t *testing.T) {
fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once()
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once()
- reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -238,7 +236,7 @@ func TestDashboardFileReader(t *testing.T) {
fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once()
- reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -266,7 +264,7 @@ func TestDashboardFileReader(t *testing.T) {
fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once()
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once()
- reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -281,7 +279,7 @@ func TestDashboardFileReader(t *testing.T) {
fakeService.On("GetProvisionedDashboardData", configName).Return(nil, nil).Once()
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once()
- reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -298,7 +296,7 @@ func TestDashboardFileReader(t *testing.T) {
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2)
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(3)
- reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -315,7 +313,7 @@ func TestDashboardFileReader(t *testing.T) {
Folder: "",
}
- _, err := NewDashboardFileReader(cfg, logger, nil)
+ _, err := NewDashboardFileReader(cfg, logger, nil, nil)
require.NotNil(t, err)
})
@@ -323,7 +321,7 @@ func TestDashboardFileReader(t *testing.T) {
setup()
cfg.Options["path"] = brokenDashboards
- _, err := NewDashboardFileReader(cfg, logger, nil)
+ _, err := NewDashboardFileReader(cfg, logger, nil, nil)
require.NoError(t, err)
})
@@ -336,14 +334,14 @@ func TestDashboardFileReader(t *testing.T) {
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2)
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2)
- reader1, err := NewDashboardFileReader(cfg1, logger, nil)
+ reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore)
reader1.dashboardProvisioningService = fakeService
require.NoError(t, err)
err = reader1.walkDisk(context.Background())
require.NoError(t, err)
- reader2, err := NewDashboardFileReader(cfg2, logger, nil)
+ reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore)
reader2.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -363,7 +361,7 @@ func TestDashboardFileReader(t *testing.T) {
"folder": defaultDashboards,
},
}
- r, err := NewDashboardFileReader(cfg, logger, nil)
+ r, err := NewDashboardFileReader(cfg, logger, nil, nil)
require.NoError(t, err)
_, err = r.getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder)
@@ -383,7 +381,7 @@ func TestDashboardFileReader(t *testing.T) {
}
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{Id: 1}, nil).Once()
- r, err := NewDashboardFileReader(cfg, logger, nil)
+ r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
require.NoError(t, err)
_, err = r.getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder)
@@ -403,7 +401,7 @@ func TestDashboardFileReader(t *testing.T) {
},
}
- r, err := NewDashboardFileReader(cfg, logger, nil)
+ r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
require.NoError(t, err)
_, err = r.getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder)
@@ -458,7 +456,7 @@ func TestDashboardFileReader(t *testing.T) {
cfg.DisableDeletion = true
- reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -473,7 +471,7 @@ func TestDashboardFileReader(t *testing.T) {
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once()
fakeService.On("DeleteProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
- reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -512,6 +510,8 @@ func (ffi FakeFileInfo) Sys() interface{} {
return nil
}
-func mockGetDashboardQuery(_ context.Context, _ *models.GetDashboardQuery) error {
+type fakeDashboardStore struct{}
+
+func (fds *fakeDashboardStore) GetDashboard(_ context.Context, _ *models.GetDashboardQuery) error {
return models.ErrDashboardNotFound
}
diff --git a/pkg/services/provisioning/dashboards/validator_test.go b/pkg/services/provisioning/dashboards/validator_test.go
index 1af1e7c05ff..b3e39e3072d 100644
--- a/pkg/services/provisioning/dashboards/validator_test.go
+++ b/pkg/services/provisioning/dashboards/validator_test.go
@@ -5,7 +5,6 @@ import (
"sort"
"testing"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
@@ -19,11 +18,9 @@ const (
)
func TestDuplicatesValidator(t *testing.T) {
- bus.ClearBusHandlers()
fakeService := &dashboards.FakeDashboardProvisioning{}
defer fakeService.AssertExpectations(t)
- bus.AddHandler("test", mockGetDashboardQuery)
cfg := &config{
Name: "Default",
Type: "file",
@@ -36,7 +33,8 @@ func TestDuplicatesValidator(t *testing.T) {
t.Run("Duplicates validator should collect info about duplicate UIDs and titles within folders", func(t *testing.T) {
const folderName = "duplicates-validator-folder"
- r, err := NewDashboardFileReader(cfg, logger, nil)
+ fakeStore := &fakeDashboardStore{}
+ r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
require.NoError(t, err)
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(6)
fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(4)
@@ -55,11 +53,11 @@ func TestDuplicatesValidator(t *testing.T) {
Options: map[string]interface{}{"path": dashboardContainingUID},
}
- reader1, err := NewDashboardFileReader(cfg1, logger, nil)
+ reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore)
reader1.dashboardProvisioningService = fakeService
require.NoError(t, err)
- reader2, err := NewDashboardFileReader(cfg2, logger, nil)
+ reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore)
reader2.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -91,7 +89,8 @@ func TestDuplicatesValidator(t *testing.T) {
t.Run("Duplicates validator should not collect info about duplicate UIDs and titles within folders for different orgs", func(t *testing.T) {
const folderName = "duplicates-validator-folder"
- r, err := NewDashboardFileReader(cfg, logger, nil)
+ fakeStore := &fakeDashboardStore{}
+ r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
require.NoError(t, err)
folderID, err := r.getOrCreateFolderID(context.Background(), cfg, fakeService, folderName)
require.NoError(t, err)
@@ -107,11 +106,11 @@ func TestDuplicatesValidator(t *testing.T) {
Options: map[string]interface{}{"path": dashboardContainingUID},
}
- reader1, err := NewDashboardFileReader(cfg1, logger, nil)
+ reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore)
reader1.dashboardProvisioningService = fakeService
require.NoError(t, err)
- reader2, err := NewDashboardFileReader(cfg2, logger, nil)
+ reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore)
reader2.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -154,6 +153,7 @@ func TestDuplicatesValidator(t *testing.T) {
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(5)
fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(3)
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(5)
+ fakeStore := &fakeDashboardStore{}
cfg1 := &config{
Name: "first", Type: "file", OrgID: 1, Folder: "duplicates-validator-folder",
@@ -167,15 +167,15 @@ func TestDuplicatesValidator(t *testing.T) {
Name: "third", Type: "file", OrgID: 2, Folder: "duplicates-validator-folder",
Options: map[string]interface{}{"path": twoDashboardsWithUID},
}
- reader1, err := NewDashboardFileReader(cfg1, logger, nil)
+ reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore)
reader1.dashboardProvisioningService = fakeService
require.NoError(t, err)
- reader2, err := NewDashboardFileReader(cfg2, logger, nil)
+ reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore)
reader2.dashboardProvisioningService = fakeService
require.NoError(t, err)
- reader3, err := NewDashboardFileReader(cfg3, logger, nil)
+ reader3, err := NewDashboardFileReader(cfg3, logger, nil, fakeStore)
reader3.dashboardProvisioningService = fakeService
require.NoError(t, err)
@@ -192,7 +192,7 @@ func TestDuplicatesValidator(t *testing.T) {
duplicates := duplicateValidator.getDuplicates()
- r, err := NewDashboardFileReader(cfg, logger, nil)
+ r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore)
require.NoError(t, err)
folderID, err := r.getOrCreateFolderID(context.Background(), cfg, fakeService, cfg1.Folder)
require.NoError(t, err)
@@ -209,7 +209,7 @@ func TestDuplicatesValidator(t *testing.T) {
sort.Strings(titleUsageReaders)
require.Equal(t, []string{"first"}, titleUsageReaders)
- r, err = NewDashboardFileReader(cfg3, logger, nil)
+ r, err = NewDashboardFileReader(cfg3, logger, nil, fakeStore)
require.NoError(t, err)
folderID, err = r.getOrCreateFolderID(context.Background(), cfg3, fakeService, cfg3.Folder)
require.NoError(t, err)
diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go
index 4fb9202524f..091d3329aed 100644
--- a/pkg/services/provisioning/provisioning.go
+++ b/pkg/services/provisioning/provisioning.go
@@ -187,7 +187,7 @@ func (ps *ProvisioningServiceImpl) ProvisionNotifications(ctx context.Context) e
func (ps *ProvisioningServiceImpl) ProvisionDashboards(ctx context.Context) error {
dashboardPath := filepath.Join(ps.Cfg.ProvisioningPath, "dashboards")
- dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.dashboardService, ps.SQLStore)
+ dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.dashboardService, ps.SQLStore, ps.SQLStore)
if err != nil {
return errutil.Wrap("Failed to create provisioner", err)
}
diff --git a/pkg/services/provisioning/provisioning_test.go b/pkg/services/provisioning/provisioning_test.go
index 4493d6569dd..ad2547e3ed6 100644
--- a/pkg/services/provisioning/provisioning_test.go
+++ b/pkg/services/provisioning/provisioning_test.go
@@ -93,7 +93,7 @@ func setup() *serviceTestStruct {
}
serviceTest.service = newProvisioningServiceImpl(
- func(context.Context, string, dashboardstore.DashboardProvisioningService, utils.OrgStore) (dashboards.DashboardProvisioner, error) {
+ func(context.Context, string, dashboardstore.DashboardProvisioningService, utils.OrgStore, utils.DashboardStore) (dashboards.DashboardProvisioner, error) {
return serviceTest.mock, nil
},
nil,
diff --git a/pkg/services/provisioning/utils/utils.go b/pkg/services/provisioning/utils/utils.go
index 18f74408b52..315f4fb251e 100644
--- a/pkg/services/provisioning/utils/utils.go
+++ b/pkg/services/provisioning/utils/utils.go
@@ -12,6 +12,10 @@ type OrgStore interface {
GetOrgById(context.Context, *models.GetOrgByIdQuery) error
}
+type DashboardStore interface {
+ GetDashboard(context.Context, *models.GetDashboardQuery) error
+}
+
func CheckOrgExists(ctx context.Context, store OrgStore, orgID int64) error {
query := models.GetOrgByIdQuery{Id: orgID}
if err := store.GetOrgById(ctx, &query); err != nil {
diff --git a/pkg/services/queryhistory/queryhistory_search_test.go b/pkg/services/queryhistory/queryhistory_search_test.go
index f459cd4fa7e..9d3db5ca328 100644
--- a/pkg/services/queryhistory/queryhistory_search_test.go
+++ b/pkg/services/queryhistory/queryhistory_search_test.go
@@ -111,7 +111,7 @@ func TestGetQueriesFromQueryHistory(t *testing.T) {
err := json.Unmarshal(resp.Body(), &response)
require.NoError(t, err)
require.Equal(t, 200, resp.Status())
- require.Equal(t, 1, response.Result.TotalCount)
+ require.Equal(t, 2, response.Result.TotalCount)
require.Equal(t, true, response.Result.QueryHistory[0].Starred)
})
}
diff --git a/pkg/services/queryhistory/queryhistory_test.go b/pkg/services/queryhistory/queryhistory_test.go
index fe9dfecedeb..b88c3161309 100644
--- a/pkg/services/queryhistory/queryhistory_test.go
+++ b/pkg/services/queryhistory/queryhistory_test.go
@@ -115,6 +115,12 @@ func testScenarioWithMultipleQueriesInQueryHistory(t *testing.T, desc string, fn
resp1 := sc.service.createHandler(sc.reqContext)
sc.initialResult = validateAndUnMarshalResponse(t, resp1)
+ // Add comment
+ cmd := PatchQueryCommentInQueryHistoryCommand{Comment: "test comment 2"}
+ sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID})
+ sc.reqContext.Req.Body = mockRequestBody(cmd)
+ sc.service.patchCommentHandler(sc.reqContext)
+
time.Sleep(1 * time.Second)
command2 := CreateQueryInQueryHistoryCommand{
DatasourceUID: testDsUID1,
diff --git a/pkg/services/queryhistory/writers.go b/pkg/services/queryhistory/writers.go
index 20f94ab1705..034a055ecfb 100644
--- a/pkg/services/queryhistory/writers.go
+++ b/pkg/services/queryhistory/writers.go
@@ -23,12 +23,12 @@ func writeStarredSQL(query SearchInQueryHistoryQuery, sqlStore *sqlstore.SQLStor
}
func writeFiltersSQL(query SearchInQueryHistoryQuery, user *models.SignedInUser, sqlStore *sqlstore.SQLStore, builder *sqlstore.SQLBuilder) {
- params := []interface{}{user.OrgId, user.UserId, "%" + query.SearchString + "%"}
+ params := []interface{}{user.OrgId, user.UserId, "%" + query.SearchString + "%", "%" + query.SearchString + "%"}
for _, uid := range query.DatasourceUIDs {
params = append(params, uid)
}
var sql bytes.Buffer
- sql.WriteString(" WHERE query_history.org_id = ? AND query_history.created_by = ? AND query_history.queries " + sqlStore.Dialect.LikeStr() + " ? AND query_history.datasource_uid IN (? " + strings.Repeat(",?", len(query.DatasourceUIDs)-1) + ") ")
+ sql.WriteString(" WHERE query_history.org_id = ? AND query_history.created_by = ? AND (query_history.queries " + sqlStore.Dialect.LikeStr() + " ? OR query_history.comment " + sqlStore.Dialect.LikeStr() + " ?) AND query_history.datasource_uid IN (? " + strings.Repeat(",?", len(query.DatasourceUIDs)-1) + ") ")
builder.Write(sql.String(), params...)
}
diff --git a/pkg/services/search/service.go b/pkg/services/search/service.go
index d8b3eb2e95a..1656f6ee123 100644
--- a/pkg/services/search/service.go
+++ b/pkg/services/search/service.go
@@ -21,7 +21,6 @@ func ProvideService(cfg *setting.Cfg, bus bus.Bus, sqlstore *sqlstore.SQLStore)
},
sqlstore: sqlstore,
}
- s.Bus.AddHandler(s.SearchHandler)
return s
}
diff --git a/pkg/services/searchV2/extract/testdata/devdash-all-panels-info.json b/pkg/services/searchV2/extract/testdata/devdash-all-panels-info.json
index 1818176ca66..26e3218ec73 100644
--- a/pkg/services/searchV2/extract/testdata/devdash-all-panels-info.json
+++ b/pkg/services/searchV2/extract/testdata/devdash-all-panels-info.json
@@ -55,11 +55,6 @@
"type": "dashlist",
"pluginVersion": "8.1.0-pre"
},
- {
- "id": 30,
- "title": "Panel list",
- "type": "pluginlist"
- },
{
"id": 6,
"title": "Alert list",
diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go
index 7e79cb6d24c..d4b07cb64db 100644
--- a/pkg/services/sqlstore/alert.go
+++ b/pkg/services/sqlstore/alert.go
@@ -7,23 +7,12 @@ import (
"strings"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
)
// timeNow makes it possible to test usage of time
var timeNow = time.Now
-func (ss *SQLStore) addAlertQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.HandleAlertsQuery)
- bus.AddHandler("sql", ss.GetAlertById)
- bus.AddHandler("sql", ss.GetAllAlertQueryHandler)
- bus.AddHandler("sql", ss.SetAlertState)
- bus.AddHandler("sql", ss.GetAlertStatesForDashboard)
- bus.AddHandler("sql", ss.PauseAlert)
- bus.AddHandler("sql", ss.PauseAllAlerts)
-}
-
func (ss *SQLStore) GetAlertById(ctx context.Context, query *models.GetAlertByIdQuery) error {
return ss.WithDbSession(ctx, func(sess *DBSession) error {
alert := models.Alert{}
diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go
index 0d6bd261415..c8c515e81ee 100644
--- a/pkg/services/sqlstore/alert_notification.go
+++ b/pkg/services/sqlstore/alert_notification.go
@@ -8,7 +8,6 @@ import (
"strings"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/util"
)
@@ -79,10 +78,6 @@ func (ss *SQLStore) GetAlertNotifications(ctx context.Context, query *models.Get
return getAlertNotificationInternal(ctx, query, newSession(ctx))
}
-func (ss *SQLStore) addAlertNotificationUidByIdHandler() {
- bus.AddHandler("sql", ss.GetAlertNotificationUidWithId)
-}
-
func (ss *SQLStore) GetAlertNotificationUidWithId(ctx context.Context, query *models.GetAlertNotificationUidQuery) error {
cacheKey := newAlertNotificationUidCacheKey(query.OrgId, query.Id)
diff --git a/pkg/services/sqlstore/alert_notification_test.go b/pkg/services/sqlstore/alert_notification_test.go
index 6fae4142bab..6d2b7a3d9b7 100644
--- a/pkg/services/sqlstore/alert_notification_test.go
+++ b/pkg/services/sqlstore/alert_notification_test.go
@@ -10,7 +10,6 @@ import (
"testing"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
@@ -19,13 +18,7 @@ import (
func TestAlertNotificationSQLAccess(t *testing.T) {
var sqlStore *SQLStore
- setup := func() {
- sqlStore = InitTestDB(t)
- // Set up bus handlers
- bus.AddHandler("deleteAlertNotification", func(ctx context.Context, cmd *models.DeleteAlertNotificationCommand) error {
- return sqlStore.DeleteAlertNotification(ctx, cmd)
- })
- }
+ setup := func() { sqlStore = InitTestDB(t) }
t.Run("Alert notification state", func(t *testing.T) {
setup()
diff --git a/pkg/services/sqlstore/apikey.go b/pkg/services/sqlstore/apikey.go
index eb31a56efaf..876d41382d2 100644
--- a/pkg/services/sqlstore/apikey.go
+++ b/pkg/services/sqlstore/apikey.go
@@ -6,18 +6,9 @@ import (
"xorm.io/xorm"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
)
-func (ss *SQLStore) addAPIKeysQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.GetAPIKeys)
- bus.AddHandler("sql", ss.GetApiKeyById)
- bus.AddHandler("sql", ss.GetApiKeyByName)
- bus.AddHandler("sql", ss.DeleteApiKey)
- bus.AddHandler("sql", ss.AddAPIKey)
-}
-
// GetAPIKeys queries the database based
// on input on GetApiKeysQuery
func (ss *SQLStore) GetAPIKeys(ctx context.Context, query *models.GetApiKeysQuery) error {
diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go
index c53c577a886..4017e8d8872 100644
--- a/pkg/services/sqlstore/dashboard.go
+++ b/pkg/services/sqlstore/dashboard.go
@@ -6,7 +6,6 @@ import (
"github.com/prometheus/client_golang/prometheus"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/sqlstore/permissions"
@@ -26,18 +25,6 @@ func init() {
prometheus.MustRegister(shadowSearchCounter)
}
-func (ss *SQLStore) addDashboardQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.GetDashboard)
- bus.AddHandler("sql", ss.GetDashboardUIDById)
- bus.AddHandler("sql", ss.GetDashboardTags)
- bus.AddHandler("sql", ss.SearchDashboards)
- bus.AddHandler("sql", ss.GetDashboards)
- bus.AddHandler("sql", ss.HasEditPermissionInFolders)
- bus.AddHandler("sql", ss.GetDashboardPermissionsForUser)
- bus.AddHandler("sql", ss.GetDashboardSlugById)
- bus.AddHandler("sql", ss.HasAdminPermissionInFolders)
-}
-
var generateNewUid func() string = util.GenerateShortUID
func (ss *SQLStore) GetDashboard(ctx context.Context, query *models.GetDashboardQuery) error {
diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go
index bcc04b2a17a..df4a4fcf129 100644
--- a/pkg/services/sqlstore/dashboard_acl.go
+++ b/pkg/services/sqlstore/dashboard_acl.go
@@ -3,14 +3,9 @@ package sqlstore
import (
"context"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
)
-func (ss *SQLStore) addDashboardACLQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.GetDashboardAclInfoList)
-}
-
// GetDashboardAclInfoList returns a list of permissions for a dashboard. They can be fetched from three
// different places.
// 1) Permissions for the dashboard
diff --git a/pkg/services/sqlstore/dashboard_version.go b/pkg/services/sqlstore/dashboard_version.go
index 3c6c6832ef8..e8bd87b6461 100644
--- a/pkg/services/sqlstore/dashboard_version.go
+++ b/pkg/services/sqlstore/dashboard_version.go
@@ -4,17 +4,10 @@ import (
"context"
"strings"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
)
-func (ss *SQLStore) addDashboardVersionQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.GetDashboardVersion)
- bus.AddHandler("sql", ss.GetDashboardVersions)
- bus.AddHandler("sql", ss.DeleteExpiredVersions)
-}
-
// GetDashboardVersion gets the dashboard version for the given dashboard ID and version number.
func (ss *SQLStore) GetDashboardVersion(ctx context.Context, query *models.GetDashboardVersionQuery) error {
return ss.WithDbSession(ctx, func(sess *DBSession) error {
diff --git a/pkg/services/sqlstore/login_attempt.go b/pkg/services/sqlstore/login_attempt.go
index fbbf85d786b..dd93a249552 100644
--- a/pkg/services/sqlstore/login_attempt.go
+++ b/pkg/services/sqlstore/login_attempt.go
@@ -5,18 +5,11 @@ import (
"strconv"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
)
var getTimeNow = time.Now
-func (ss *SQLStore) addLoginAttemptQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.CreateLoginAttempt)
- bus.AddHandler("sql", ss.DeleteOldLoginAttempts)
- bus.AddHandler("sql", ss.GetUserLoginAttemptCount)
-}
-
func (ss *SQLStore) CreateLoginAttempt(ctx context.Context, cmd *models.CreateLoginAttemptCommand) error {
return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error {
loginAttempt := models.LoginAttempt{
diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go
index 2094f0100da..f66b16b723e 100644
--- a/pkg/services/sqlstore/mockstore/mockstore.go
+++ b/pkg/services/sqlstore/mockstore/mockstore.go
@@ -44,7 +44,8 @@ type SQLStoreMock struct {
ExpectedUserStars map[int64]bool
ExpectedLoginAttempts int64
- ExpectedError error
+ ExpectedError error
+ ExpectedSetUsingOrgError error
}
func NewSQLStoreMock() *SQLStoreMock {
@@ -178,7 +179,7 @@ func (m *SQLStoreMock) UpdateUserLastSeenAt(ctx context.Context, cmd *models.Upd
}
func (m *SQLStoreMock) SetUsingOrg(ctx context.Context, cmd *models.SetUsingOrgCommand) error {
- return m.ExpectedError
+ return m.ExpectedSetUsingOrgError
}
func (m *SQLStoreMock) GetUserProfile(ctx context.Context, query *models.GetUserProfileQuery) error {
diff --git a/pkg/services/sqlstore/org.go b/pkg/services/sqlstore/org.go
index 6cac1cea15f..1cdd72a938a 100644
--- a/pkg/services/sqlstore/org.go
+++ b/pkg/services/sqlstore/org.go
@@ -5,7 +5,6 @@ import (
"fmt"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/events"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
@@ -15,16 +14,6 @@ import (
// MainOrgName is the name of the main organization.
const MainOrgName = "Main Org."
-func (ss *SQLStore) addOrgQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.GetOrgById)
- bus.AddHandler("sql", CreateOrg)
- bus.AddHandler("sql", ss.UpdateOrg)
- bus.AddHandler("sql", ss.UpdateOrgAddress)
- bus.AddHandler("sql", ss.GetOrgByNameHandler)
- bus.AddHandler("sql", ss.SearchOrgs)
- bus.AddHandler("sql", ss.DeleteOrg)
-}
-
func (ss *SQLStore) SearchOrgs(ctx context.Context, query *models.SearchOrgsQuery) error {
return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
query.Result = make([]*models.OrgDTO, 0)
diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go
index 24f1a9e8119..60d7e5ca8f7 100644
--- a/pkg/services/sqlstore/org_users.go
+++ b/pkg/services/sqlstore/org_users.go
@@ -6,20 +6,12 @@ import (
"strings"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/util"
)
-func (ss *SQLStore) addOrgUsersQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.AddOrgUser)
- bus.AddHandler("sql", ss.RemoveOrgUser)
- bus.AddHandler("sql", ss.GetOrgUsers)
- bus.AddHandler("sql", ss.UpdateOrgUser)
-}
-
func (ss *SQLStore) AddOrgUser(ctx context.Context, cmd *models.AddOrgUserCommand) error {
return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error {
// check if user exists
diff --git a/pkg/services/sqlstore/playlist.go b/pkg/services/sqlstore/playlist.go
index b0d8e96652d..49f3aa311c8 100644
--- a/pkg/services/sqlstore/playlist.go
+++ b/pkg/services/sqlstore/playlist.go
@@ -3,19 +3,9 @@ package sqlstore
import (
"context"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
)
-func (ss *SQLStore) addPlaylistQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.CreatePlaylist)
- bus.AddHandler("sql", ss.UpdatePlaylist)
- bus.AddHandler("sql", ss.DeletePlaylist)
- bus.AddHandler("sql", ss.SearchPlaylists)
- bus.AddHandler("sql", ss.GetPlaylist)
- bus.AddHandler("sql", ss.GetPlaylistItem)
-}
-
func (ss *SQLStore) CreatePlaylist(ctx context.Context, cmd *models.CreatePlaylistCommand) error {
return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error {
playlist := models.Playlist{
diff --git a/pkg/services/sqlstore/preferences.go b/pkg/services/sqlstore/preferences.go
index 1fccba8adc3..da3a5388ef1 100644
--- a/pkg/services/sqlstore/preferences.go
+++ b/pkg/services/sqlstore/preferences.go
@@ -5,17 +5,9 @@ import (
"strings"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
)
-func (ss *SQLStore) addPreferencesQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.GetPreferences)
- bus.AddHandler("sql", ss.GetPreferencesWithDefaults)
- bus.AddHandler("sql", ss.SavePreferences)
- bus.AddHandler("sql", ss.PatchPreferences)
-}
-
func (ss *SQLStore) GetPreferencesWithDefaults(ctx context.Context, query *models.GetPreferencesWithDefaultsQuery) error {
return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
params := make([]interface{}, 0)
diff --git a/pkg/services/sqlstore/quota.go b/pkg/services/sqlstore/quota.go
index abc0c39b98c..3ed4428fa71 100644
--- a/pkg/services/sqlstore/quota.go
+++ b/pkg/services/sqlstore/quota.go
@@ -5,7 +5,6 @@ import (
"fmt"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
)
@@ -15,16 +14,6 @@ const (
dashboardTarget = "dashboard"
)
-func (ss *SQLStore) addQuotaQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.GetOrgQuotaByTarget)
- bus.AddHandler("sql", ss.GetOrgQuotas)
- bus.AddHandler("sql", ss.UpdateOrgQuota)
- bus.AddHandler("sql", ss.GetUserQuotaByTarget)
- bus.AddHandler("sql", ss.GetUserQuotas)
- bus.AddHandler("sql", ss.UpdateUserQuota)
- bus.AddHandler("sql", ss.GetGlobalQuotaByTarget)
-}
-
type targetCount struct {
Count int64
}
diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go
index 792ce846329..ca6ec8d6135 100644
--- a/pkg/services/sqlstore/sqlstore.go
+++ b/pkg/services/sqlstore/sqlstore.go
@@ -111,28 +111,6 @@ func newSQLStore(cfg *setting.Cfg, cacheService *localcache.CacheService, b bus.
// Init repo instances
annotations.SetRepository(&SQLAnnotationRepo{sql: ss})
annotations.SetAnnotationCleaner(&AnnotationCleanupService{batchSize: ss.Cfg.AnnotationCleanupJobBatchSize, log: log.New("annotationcleaner")})
- ss.Bus.SetTransactionManager(ss)
-
- // Register handlers
- ss.addStatsQueryAndCommandHandlers()
- ss.addUserQueryAndCommandHandlers()
- ss.addAlertNotificationUidByIdHandler()
- ss.addPreferencesQueryAndCommandHandlers()
- ss.addDashboardQueryAndCommandHandlers()
- ss.addDashboardACLQueryAndCommandHandlers()
- ss.addQuotaQueryAndCommandHandlers()
- ss.addOrgUsersQueryAndCommandHandlers()
- ss.addStarQueryAndCommandHandlers()
- ss.addAlertQueryAndCommandHandlers()
- ss.addTempUserQueryAndCommandHandlers()
- ss.addDashboardVersionQueryAndCommandHandlers()
- ss.addAPIKeysQueryAndCommandHandlers()
- ss.addPlaylistQueryAndCommandHandlers()
- ss.addLoginAttemptQueryAndCommandHandlers()
- ss.addTeamQueryAndCommandHandlers()
- ss.addOrgQueryAndCommandHandlers()
-
- bus.AddHandler("sql", ss.GetDBHealthQuery)
// if err := ss.Reset(); err != nil {
// return nil, err
diff --git a/pkg/services/sqlstore/star.go b/pkg/services/sqlstore/star.go
index fef91f9e9b4..847fdfec6e0 100644
--- a/pkg/services/sqlstore/star.go
+++ b/pkg/services/sqlstore/star.go
@@ -3,17 +3,9 @@ package sqlstore
import (
"context"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
)
-func (ss *SQLStore) addStarQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.StarDashboard)
- bus.AddHandler("sql", ss.UnstarDashboard)
- bus.AddHandler("sql", ss.GetUserStars)
- bus.AddHandler("sql", ss.IsStarredByUserCtx)
-}
-
func (ss *SQLStore) IsStarredByUserCtx(ctx context.Context, query *models.IsStarredByUserQuery) error {
return ss.WithDbSession(ctx, func(sess *DBSession) error {
rawSQL := "SELECT 1 from star where user_id=? and dashboard_id=?"
diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go
index 43777190fe3..e1e1500994e 100644
--- a/pkg/services/sqlstore/stats.go
+++ b/pkg/services/sqlstore/stats.go
@@ -5,20 +5,10 @@ import (
"strconv"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
-func (ss *SQLStore) addStatsQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.GetAdminStats)
- bus.AddHandler("sql", ss.GetSystemUserCountStats)
- bus.AddHandler("sql", ss.GetAlertNotifiersUsageStats)
- bus.AddHandler("sql", ss.GetDataSourceAccessStats)
- bus.AddHandler("sql", ss.GetDataSourceStats)
- bus.AddHandler("sql", ss.GetSystemStats)
-}
-
const activeUserTimeLimit = time.Hour * 24 * 30
const dailyActiveUserTimeLimit = time.Hour * 24
diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go
index abedc2110b3..6e266fed697 100644
--- a/pkg/services/sqlstore/team.go
+++ b/pkg/services/sqlstore/team.go
@@ -7,25 +7,11 @@ import (
"strings"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
-func (ss *SQLStore) addTeamQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.UpdateTeam)
- bus.AddHandler("sql", ss.DeleteTeam)
- bus.AddHandler("sql", ss.SearchTeams)
- bus.AddHandler("sql", ss.GetTeamById)
- bus.AddHandler("sql", ss.GetTeamsByUser)
-
- bus.AddHandler("sql", ss.UpdateTeamMember)
- bus.AddHandler("sql", ss.RemoveTeamMember)
- bus.AddHandler("sql", ss.GetTeamMembers)
- bus.AddHandler("sql", IsAdminOfTeams)
-}
-
type TeamStore interface {
UpdateTeam(ctx context.Context, cmd *models.UpdateTeamCommand) error
DeleteTeam(ctx context.Context, cmd *models.DeleteTeamCommand) error
diff --git a/pkg/services/sqlstore/temp_user.go b/pkg/services/sqlstore/temp_user.go
index 97f32f77a41..80115ade956 100644
--- a/pkg/services/sqlstore/temp_user.go
+++ b/pkg/services/sqlstore/temp_user.go
@@ -4,19 +4,9 @@ import (
"context"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
)
-func (ss *SQLStore) addTempUserQueryAndCommandHandlers() {
- bus.AddHandler("sql", ss.CreateTempUser)
- bus.AddHandler("sql", ss.GetTempUsersQuery)
- bus.AddHandler("sql", ss.UpdateTempUserStatus)
- bus.AddHandler("sql", ss.GetTempUserByCode)
- bus.AddHandler("sql", ss.UpdateTempUserWithEmailSent)
- bus.AddHandler("sql", ss.ExpireOldUserInvites)
-}
-
func (ss *SQLStore) UpdateTempUserStatus(ctx context.Context, cmd *models.UpdateTempUserStatusCommand) error {
return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error {
var rawSQL = "UPDATE temp_user SET status=? WHERE code=?"
diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go
index c9ce4d9b098..b159dcd6bf8 100644
--- a/pkg/services/sqlstore/user.go
+++ b/pkg/services/sqlstore/user.go
@@ -8,7 +8,6 @@ import (
"strings"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/events"
"github.com/grafana/grafana/pkg/models"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -16,25 +15,6 @@ import (
"github.com/grafana/grafana/pkg/util"
)
-func (ss *SQLStore) addUserQueryAndCommandHandlers() {
- ss.Bus.AddHandler(ss.GetSignedInUserWithCacheCtx)
-
- bus.AddHandler("sql", ss.GetUserById)
- bus.AddHandler("sql", ss.UpdateUser)
- bus.AddHandler("sql", ss.ChangeUserPassword)
- bus.AddHandler("sql", ss.GetUserByLogin)
- bus.AddHandler("sql", ss.GetUserByEmail)
- bus.AddHandler("sql", ss.SetUsingOrg)
- bus.AddHandler("sql", ss.UpdateUserLastSeenAt)
- bus.AddHandler("sql", ss.GetUserProfile)
- bus.AddHandler("sql", ss.SearchUsers)
- bus.AddHandler("sql", ss.GetUserOrgList)
- bus.AddHandler("sql", ss.DisableUser)
- bus.AddHandler("sql", ss.BatchDisableUsers)
- bus.AddHandler("sql", ss.DeleteUser)
- bus.AddHandler("sql", ss.SetUserHelpFlag)
-}
-
func getOrgIdForNewUser(sess *DBSession, cmd models.CreateUserCommand) (int64, error) {
if cmd.SkipOrgSetup {
return -1, nil
diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json
index e83c9ab8367..2c012c8a7a5 100644
--- a/pkg/tests/api/plugins/data/expectedListResp.json
+++ b/pkg/tests/api/plugins/data/expectedListResp.json
@@ -1034,42 +1034,6 @@
"signatureType": "",
"signatureOrg": ""
},
- {
- "name": "Plugin list",
- "type": "panel",
- "id": "pluginlist",
- "enabled": true,
- "pinned": false,
- "info": {
- "author": {
- "name": "Grafana Labs",
- "url": "https://grafana.com"
- },
- "description": "Plugin List for Grafana",
- "links": null,
- "logos": {
- "small": "public/app/plugins/panel/pluginlist/img/icn-dashlist-panel.svg",
- "large": "public/app/plugins/panel/pluginlist/img/icn-dashlist-panel.svg"
- },
- "build": {},
- "screenshots": null,
- "version": "",
- "updated": ""
- },
- "dependencies": {
- "grafanaDependency": "",
- "grafanaVersion": "*",
- "plugins": []
- },
- "latestVersion": "",
- "hasUpdate": false,
- "defaultNavUrl": "/plugins/pluginlist/",
- "category": "",
- "state": "",
- "signature": "internal",
- "signatureType": "",
- "signatureOrg": ""
- },
{
"name": "PostgreSQL",
"type": "datasource",
diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go
index 3def772d76b..0fcd93f7fca 100644
--- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go
+++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go
@@ -217,7 +217,6 @@ func (e *AzureMonitorDatasource) createRequest(ctx context.Context, dsInfo types
azlog.Debug("Failed to create request", "error", err)
return nil, errutil.Wrap("Failed to create request", err)
}
- req.URL.Path = "/subscriptions"
req.Header.Set("Content-Type", "application/json")
return req, nil
diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go
index 41453545713..89faac286c0 100644
--- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go
+++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go
@@ -163,7 +163,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) {
}
azureMonitorQuery := &types.AzureMonitorQuery{
- URL: "12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana/providers/microsoft.insights/metrics",
+ URL: "/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana/providers/microsoft.insights/metrics",
UrlComponents: map[string]string{
"metricDefinition": "Microsoft.Compute/virtualMachines",
"resourceGroup": "grafanastaging",
@@ -587,7 +587,7 @@ func TestAzureMonitorCreateRequest(t *testing.T) {
}{
{
name: "creates a request",
- expectedURL: "http://ds/subscriptions",
+ expectedURL: "http://ds/",
expectedHeaders: http.Header{
"Content-Type": []string{"application/json"},
},
diff --git a/pkg/tsdb/azuremonitor/metrics/url-builder.go b/pkg/tsdb/azuremonitor/metrics/url-builder.go
index 4745f3bcdf0..8c3fad8fd37 100644
--- a/pkg/tsdb/azuremonitor/metrics/url-builder.go
+++ b/pkg/tsdb/azuremonitor/metrics/url-builder.go
@@ -18,6 +18,35 @@ type urlBuilder struct {
ResourceName string
}
+func (params *urlBuilder) buildMetricsURLFromLegacyQuery() string {
+ subscription := params.Subscription
+
+ if params.Subscription == "" {
+ subscription = params.DefaultSubscription
+ }
+
+ metricDefinitionArray := strings.Split(params.MetricDefinition, "/")
+ resourceNameArray := strings.Split(params.ResourceName, "/")
+ provider := metricDefinitionArray[0]
+ metricDefinitionArray = metricDefinitionArray[1:]
+
+ urlArray := []string{
+ "/subscriptions",
+ subscription,
+ "resourceGroups",
+ params.ResourceGroup,
+ "providers",
+ provider,
+ }
+
+ for i, metricDefinition := range metricDefinitionArray {
+ urlArray = append(urlArray, metricDefinition, resourceNameArray[i])
+ }
+
+ resourceURI := strings.Join(urlArray, "/")
+ return resourceURI
+}
+
// BuildMetricsURL checks the metric definition property to see which form of the url
// should be returned
func (params *urlBuilder) BuildMetricsURL() string {
@@ -25,31 +54,7 @@ func (params *urlBuilder) BuildMetricsURL() string {
// Prior to Grafana 9, we had a legacy query object rather than a resourceURI, so we manually create the resource URI
if resourceURI == "" {
- subscription := params.Subscription
-
- if params.Subscription == "" {
- subscription = params.DefaultSubscription
- }
-
- metricDefinitionArray := strings.Split(params.MetricDefinition, "/")
- resourceNameArray := strings.Split(params.ResourceName, "/")
- provider := metricDefinitionArray[0]
- metricDefinitionArray = metricDefinitionArray[1:]
-
- urlArray := []string{
- subscription,
- "resourceGroups",
- params.ResourceGroup,
- "providers",
- provider,
- }
-
- for i := range metricDefinitionArray {
- urlArray = append(urlArray, metricDefinitionArray[i])
- urlArray = append(urlArray, resourceNameArray[i])
- }
-
- resourceURI = strings.Join(urlArray[:], "/")
+ resourceURI = params.buildMetricsURLFromLegacyQuery()
}
return fmt.Sprintf("%s/providers/microsoft.insights/metrics", resourceURI)
diff --git a/pkg/tsdb/azuremonitor/metrics/url-builder_test.go b/pkg/tsdb/azuremonitor/metrics/url-builder_test.go
index 458cf7b491c..1abde71f094 100644
--- a/pkg/tsdb/azuremonitor/metrics/url-builder_test.go
+++ b/pkg/tsdb/azuremonitor/metrics/url-builder_test.go
@@ -3,23 +3,23 @@ package metrics
import (
"testing"
- "github.com/stretchr/testify/require"
+ "github.com/stretchr/testify/assert"
)
func TestURLBuilder(t *testing.T) {
t.Run("AzureMonitor URL Builder", func(t *testing.T) {
t.Run("when only resource uri is provided it returns resource/uri/providers/microsoft.insights/metrics", func(t *testing.T) {
ub := &urlBuilder{
- ResourceURI: "resource/uri",
+ ResourceURI: "/subscriptions/sub/resource/uri",
}
url := ub.BuildMetricsURL()
- require.Equal(t, url, "resource/uri/providers/microsoft.insights/metrics")
+ assert.Equal(t, "/subscriptions/sub/resource/uri/providers/microsoft.insights/metrics", url)
})
t.Run("when resource uri and legacy fields are provided the legacy fields are ignored", func(t *testing.T) {
ub := &urlBuilder{
- ResourceURI: "resource/uri",
+ ResourceURI: "/subscriptions/sub/resource/uri",
DefaultSubscription: "default-sub",
ResourceGroup: "rg",
MetricDefinition: "Microsoft.NetApp/netAppAccounts/capacityPools/volumes",
@@ -27,7 +27,7 @@ func TestURLBuilder(t *testing.T) {
}
url := ub.BuildMetricsURL()
- require.Equal(t, url, "resource/uri/providers/microsoft.insights/metrics")
+ assert.Equal(t, "/subscriptions/sub/resource/uri/providers/microsoft.insights/metrics", url)
})
t.Run("Legacy URL Builder params", func(t *testing.T) {
@@ -40,7 +40,7 @@ func TestURLBuilder(t *testing.T) {
}
url := ub.BuildMetricsURL()
- require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics")
+ assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics", url)
})
t.Run("when metric definition is in the short form and a subscription is defined", func(t *testing.T) {
@@ -53,7 +53,7 @@ func TestURLBuilder(t *testing.T) {
}
url := ub.BuildMetricsURL()
- require.Equal(t, url, "specified-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics")
+ assert.Equal(t, "/subscriptions/specified-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics", url)
})
t.Run("when metric definition is Microsoft.Storage/storageAccounts/blobServices", func(t *testing.T) {
@@ -65,7 +65,7 @@ func TestURLBuilder(t *testing.T) {
}
url := ub.BuildMetricsURL()
- require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/providers/microsoft.insights/metrics")
+ assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/providers/microsoft.insights/metrics", url)
})
t.Run("when metric definition is Microsoft.Storage/storageAccounts/fileServices", func(t *testing.T) {
@@ -77,7 +77,7 @@ func TestURLBuilder(t *testing.T) {
}
url := ub.BuildMetricsURL()
- require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/providers/microsoft.insights/metrics")
+ assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/providers/microsoft.insights/metrics", url)
})
t.Run("when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes", func(t *testing.T) {
@@ -89,7 +89,7 @@ func TestURLBuilder(t *testing.T) {
}
url := ub.BuildMetricsURL()
- require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/providers/microsoft.insights/metrics")
+ assert.Equal(t, "/subscriptions/default-sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/providers/microsoft.insights/metrics", url)
})
})
})
diff --git a/pkg/tsdb/azuremonitor/types/types.go b/pkg/tsdb/azuremonitor/types/types.go
index f0d40823c7b..1a9b4d60271 100644
--- a/pkg/tsdb/azuremonitor/types/types.go
+++ b/pkg/tsdb/azuremonitor/types/types.go
@@ -121,9 +121,12 @@ type AzureMonitorJSONQuery struct {
// Legecy "resource" fields from before the resource picker provided just a single ResourceURI
// These are used for pre-resource picker queries to reconstruct a resource URI
+ // Deprecated
MetricDefinition string `json:"metricDefinition"`
- ResourceGroup string `json:"resourceGroup"`
- ResourceName string `json:"resourceName"`
+ // Deprecated
+ ResourceGroup string `json:"resourceGroup"`
+ // Deprecated
+ ResourceName string `json:"resourceName"`
AllowedTimeGrainsMs []int64 `json:"allowedTimeGrainsMs"`
Dimension string `json:"dimension"` // old model
diff --git a/pkg/tsdb/testdatasource/scenarios.go b/pkg/tsdb/testdatasource/scenarios.go
index 1c4e5b4d26f..e49d760cef2 100644
--- a/pkg/tsdb/testdatasource/scenarios.go
+++ b/pkg/tsdb/testdatasource/scenarios.go
@@ -177,9 +177,12 @@ Timestamps will line up evenly on timeStepSeconds (For example, 60 seconds means
})
s.registerScenario(&Scenario{
- ID: string(serverError500Query),
- Name: "Server Error (500)",
- handler: s.handleServerError500Scenario,
+ // Is no longer strictly a _server_ error scenario, but ID is kept for legacy :)
+ ID: string(serverError500Query),
+ Name: "Conditional Error",
+ handler: s.handleServerError500Scenario,
+ StringInput: "1,20,90,30,5,0",
+ Description: "Returns an error when the String Input field is empty",
})
s.registerScenario(&Scenario{
@@ -449,7 +452,19 @@ func (s *Service) handlePredictablePulseScenario(ctx context.Context, req *backe
}
func (s *Service) handleServerError500Scenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
- panic("Test Data Panic!")
+ for _, q := range req.Queries {
+ model, err := simplejson.NewJson(q.JSON)
+ if err != nil {
+ continue
+ }
+
+ stringInput := model.Get("stringInput").MustString()
+ if stringInput == "" {
+ panic("Test Data Panic!")
+ }
+ }
+
+ return s.handleCSVMetricValuesScenario(ctx, req)
}
func (s *Service) handleClientSideScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
diff --git a/plugins-bundled/internal/input-datasource/package.json b/plugins-bundled/internal/input-datasource/package.json
index ce4d3ebc8aa..df76caba401 100644
--- a/plugins-bundled/internal/input-datasource/package.json
+++ b/plugins-bundled/internal/input-datasource/package.json
@@ -1,6 +1,6 @@
{
"name": "@grafana-plugins/input-datasource",
- "version": "8.5.0-pre",
+ "version": "9.0.0-pre",
"description": "Input Datasource",
"private": true,
"repository": {
@@ -24,9 +24,9 @@
"webpack": "5.58.1"
},
"dependencies": {
- "@grafana/data": "8.5.0-pre",
- "@grafana/toolkit": "8.5.0-pre",
- "@grafana/ui": "8.5.0-pre",
+ "@grafana/data": "9.0.0-pre",
+ "@grafana/toolkit": "9.0.0-pre",
+ "@grafana/ui": "9.0.0-pre",
"jquery": "3.5.1",
"react": "17.0.1",
"react-dom": "17.0.1",
diff --git a/public/app/core/components/NavBar/Next/NavBarMenu.tsx b/public/app/core/components/NavBar/Next/NavBarMenu.tsx
index 751de398665..b363797036f 100644
--- a/public/app/core/components/NavBar/Next/NavBarMenu.tsx
+++ b/public/app/core/components/NavBar/Next/NavBarMenu.tsx
@@ -1,7 +1,7 @@
import React, { useRef } from 'react';
import CSSTransition from 'react-transition-group/CSSTransition';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
-import { CollapsableSection, CustomScrollbar, Icon, IconName, useStyles2, useTheme2 } from '@grafana/ui';
+import { CollapsableSection, CustomScrollbar, Icon, IconButton, IconName, useStyles2, useTheme2 } from '@grafana/ui';
import { FocusScope } from '@react-aria/focus';
import { useDialog } from '@react-aria/dialog';
import { OverlayContainer, useOverlay } from '@react-aria/overlays';
@@ -48,6 +48,16 @@ export function NavBarMenu({ activeItem, isOpen, navItems, onClose, setMenuAnima
timeout={ANIMATION_DURATION}
>
+
+
+
+
@@ -82,8 +92,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
zIndex: theme.zIndex.modalBackdrop,
}),
container: css({
- bottom: 0,
display: 'flex',
+ bottom: 0,
flexDirection: 'column',
left: 0,
whiteSpace: 'nowrap',
@@ -104,6 +114,15 @@ const getStyles = (theme: GrafanaTheme2) => ({
flexDirection: 'column',
overflow: 'auto',
}),
+ mobileHeader: css({
+ borderBottom: `1px solid ${theme.colors.border.weak}`,
+ display: 'flex',
+ justifyContent: 'space-between',
+ padding: theme.spacing(1, 2, 2),
+ [theme.breakpoints.up('md')]: {
+ display: 'none',
+ },
+ }),
itemList: css({
display: 'grid',
gridAutoRows: `minmax(${theme.spacing(6)}, auto)`,
@@ -118,14 +137,16 @@ const getStyles = (theme: GrafanaTheme2) => ({
const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => {
const commonTransition = {
- transitionProperty: 'width, background-color, opacity',
transitionDuration: `${animationDuration}ms`,
transitionTimingFunction: theme.transitions.easing.easeInOut,
+ [theme.breakpoints.down('md')]: {
+ overflow: 'hidden',
+ },
};
const overlayTransition = {
...commonTransition,
- transitionProperty: 'width, background-color, box-shadow',
+ transitionProperty: 'background-color, box-shadow, width',
};
const backdropTransition = {
@@ -136,13 +157,19 @@ const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => {
const overlayOpen = {
backgroundColor: theme.colors.background.canvas,
boxShadow: theme.shadows.z3,
- width: '300px',
+ width: '100%',
+ [theme.breakpoints.up('md')]: {
+ width: '300px',
+ },
};
const overlayClosed = {
- backgroundColor: theme.colors.background.primary,
boxShadow: 'none',
- width: theme.spacing(7),
+ width: 0,
+ [theme.breakpoints.up('md')]: {
+ backgroundColor: theme.colors.background.primary,
+ width: theme.spacing(7),
+ },
};
const backdropOpen = {
diff --git a/public/app/core/components/NavBar/Next/NavBarToggle.tsx b/public/app/core/components/NavBar/Next/NavBarToggle.tsx
index fc830d0fe0f..617958cc046 100644
--- a/public/app/core/components/NavBar/Next/NavBarToggle.tsx
+++ b/public/app/core/components/NavBar/Next/NavBarToggle.tsx
@@ -16,6 +16,7 @@ export const NavBarToggle = ({ className, isExpanded, onClick }: Props) => {
return (
{
expect(logsModel.meta).toHaveLength(2);
expect(logsModel.meta![0]).toMatchObject({
label: COMMON_LABELS,
- value: series[0].fields[1].labels,
+ value: {
+ filename: '/var/log/grafana/grafana.log',
+ job: 'grafana',
+ },
kind: LogsMetaKind.LabelsMap,
});
expect(logsModel.meta![1]).toMatchObject({
@@ -342,6 +345,147 @@ describe('dataFrameToLogsModel', () => {
});
});
+ it('given one series with labels-field should return expected logs model', () => {
+ const series: DataFrame[] = [
+ new MutableDataFrame({
+ fields: [
+ {
+ name: 'labels',
+ type: FieldType.other,
+ values: [
+ {
+ filename: '/var/log/grafana/grafana.log',
+ job: 'grafana',
+ },
+ {
+ filename: '/var/log/grafana/grafana.log',
+ job: 'grafana',
+ },
+ ],
+ },
+ {
+ name: 'time',
+ type: FieldType.time,
+ values: ['2019-04-26T09:28:11.352440161Z', '2019-04-26T14:42:50.991981292Z'],
+ },
+ {
+ name: 'message',
+ type: FieldType.string,
+ values: [
+ 't=2019-04-26T11:05:28+0200 lvl=info msg="Initializing DatasourceCacheService" logger=server',
+ 't=2019-04-26T16:42:50+0200 lvl=eror msg="new token…t unhashed token=56d9fdc5c8b7400bd51b060eea8ca9d7',
+ ],
+ },
+ {
+ name: 'id',
+ type: FieldType.string,
+ values: ['foo', 'bar'],
+ },
+ ],
+ meta: {
+ limit: 1000,
+ },
+ }),
+ ];
+ const logsModel = dataFrameToLogsModel(series, 1);
+ expect(logsModel.hasUniqueLabels).toBeFalsy();
+ expect(logsModel.rows).toHaveLength(2);
+ expect(logsModel.rows).toMatchObject([
+ {
+ entry: 't=2019-04-26T11:05:28+0200 lvl=info msg="Initializing DatasourceCacheService" logger=server',
+ labels: { filename: '/var/log/grafana/grafana.log', job: 'grafana' },
+ logLevel: 'info',
+ uniqueLabels: {},
+ uid: 'foo',
+ },
+ {
+ entry: 't=2019-04-26T16:42:50+0200 lvl=eror msg="new token…t unhashed token=56d9fdc5c8b7400bd51b060eea8ca9d7',
+ labels: { filename: '/var/log/grafana/grafana.log', job: 'grafana' },
+ logLevel: 'error',
+ uniqueLabels: {},
+ uid: 'bar',
+ },
+ ]);
+
+ expect(logsModel.series).toHaveLength(2);
+ expect(logsModel.series).toMatchObject([
+ {
+ name: 'info',
+ fields: [
+ { type: 'time', values: new ArrayVector([1556270891000, 1556289770000]) },
+ { type: 'number', values: new ArrayVector([1, 0]) },
+ ],
+ },
+ {
+ name: 'error',
+ fields: [
+ { type: 'time', values: new ArrayVector([1556289770000]) },
+ { type: 'number', values: new ArrayVector([1]) },
+ ],
+ },
+ ]);
+ expect(logsModel.meta).toHaveLength(2);
+ expect(logsModel.meta![0]).toMatchObject({
+ label: COMMON_LABELS,
+ value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' },
+ kind: LogsMetaKind.LabelsMap,
+ });
+ expect(logsModel.meta![1]).toMatchObject({
+ label: LIMIT_LABEL,
+ value: `1000 (2 returned)`,
+ kind: LogsMetaKind.String,
+ });
+ });
+
+ it('given one series with labels-field it should work regardless the label-fields position', () => {
+ const labels = {
+ name: 'labels',
+ type: FieldType.other,
+ values: [
+ {
+ node: 'first',
+ mode: 'slow',
+ },
+ ],
+ };
+
+ const time = {
+ name: 'time',
+ type: FieldType.time,
+ values: ['2019-04-26T09:28:11.352440161Z'],
+ };
+
+ const line = {
+ name: 'line',
+ type: FieldType.string,
+ values: ['line1'],
+ };
+
+ const frame1 = new MutableDataFrame({
+ fields: [labels, time, line],
+ });
+
+ const frame2 = new MutableDataFrame({
+ fields: [time, labels, line],
+ });
+
+ const frame3 = new MutableDataFrame({
+ fields: [time, line, labels],
+ });
+
+ const logsModel1 = dataFrameToLogsModel([frame1], 1);
+ expect(logsModel1.rows).toHaveLength(1);
+ expect(logsModel1.rows[0].labels).toStrictEqual({ mode: 'slow', node: 'first' });
+
+ const logsModel2 = dataFrameToLogsModel([frame2], 1);
+ expect(logsModel2.rows).toHaveLength(1);
+ expect(logsModel2.rows[0].labels).toStrictEqual({ mode: 'slow', node: 'first' });
+
+ const logsModel3 = dataFrameToLogsModel([frame3], 1);
+ expect(logsModel3.rows).toHaveLength(1);
+ expect(logsModel3.rows[0].labels).toStrictEqual({ mode: 'slow', node: 'first' });
+ });
+
it('given one series with error should return expected logs model', () => {
const series: DataFrame[] = [
new MutableDataFrame({
diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts
index 3bee4f2b5d3..36316d6a85e 100644
--- a/public/app/core/logs_model.ts
+++ b/public/app/core/logs_model.ts
@@ -303,11 +303,38 @@ interface LogFields {
timeField: FieldWithIndex;
stringField: FieldWithIndex;
+ labelsField?: FieldWithIndex;
timeNanosecondField?: FieldWithIndex;
logLevelField?: FieldWithIndex;
idField?: FieldWithIndex;
}
+function getAllLabels(fields: LogFields): Labels[] {
+ // there are two types of dataframes we handle:
+ // 1. labels are in a separate field (more efficient when labels change by every log-row)
+ // 2. labels are in in the string-field's `.labels` attribute
+
+ const { stringField, labelsField } = fields;
+
+ const fieldLabels = stringField.labels !== undefined ? [stringField.labels] : [];
+
+ const labelsFieldLabels: Labels[] = labelsField !== undefined ? labelsField.values.toArray() : [];
+
+ return [...fieldLabels, ...labelsFieldLabels];
+}
+
+function getLabelsForFrameRow(fields: LogFields, index: number): Labels {
+ // there are two types of dataframes we handle.
+ // either labels-on-the-string-field, or labels-in-the-labels-field
+
+ const { stringField, labelsField } = fields;
+
+ return {
+ ...stringField.labels,
+ ...labelsField?.values.get(index),
+ };
+}
+
/**
* Converts dataFrames into LogsModel. This involves merging them into one list, sorting them and computing metadata
* like common labels.
@@ -316,7 +343,7 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi
if (logSeries.length === 0) {
return undefined;
}
- const allLabels: Labels[] = [];
+ const allLabels: Labels[][] = [];
// Find the fields we care about and collect all labels
let allSeries: LogFields[] = [];
@@ -330,43 +357,39 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi
const fieldCache = new FieldCache(series);
const stringField = fieldCache.getFirstFieldOfType(FieldType.string);
const timeField = fieldCache.getFirstFieldOfType(FieldType.time);
+ const labelsField = fieldCache.getFieldByName('labels');
if (stringField !== undefined && timeField !== undefined) {
- if (stringField?.labels) {
- allLabels.push(stringField.labels);
- }
-
- allSeries.push({
+ const info = {
series,
timeField,
+ labelsField,
timeNanosecondField: fieldCache.hasFieldWithNameAndType('tsNs', FieldType.time)
? fieldCache.getFieldByName('tsNs')
: undefined,
stringField,
logLevelField: fieldCache.getFieldByName('level'),
idField: getIdField(fieldCache),
- });
+ };
+
+ allSeries.push(info);
+
+ const labels = getAllLabels(info);
+ if (labels.length > 0) {
+ allLabels.push(labels);
+ }
}
});
}
- const commonLabels = allLabels.length > 0 ? findCommonLabels(allLabels) : {};
+ const flatAllLabels = allLabels.flat();
+ const commonLabels = flatAllLabels.length > 0 ? findCommonLabels(flatAllLabels) : {};
const rows: LogRowModel[] = [];
let hasUniqueLabels = false;
for (const info of allSeries) {
const { timeField, timeNanosecondField, stringField, logLevelField, idField, series } = info;
- const labels = stringField.labels;
- const uniqueLabels = findUniqueLabels(labels, commonLabels);
- if (Object.keys(uniqueLabels).length > 0) {
- hasUniqueLabels = true;
- }
-
- let seriesLogLevel: LogLevel | undefined = undefined;
- if (labels && Object.keys(labels).indexOf('level') !== -1) {
- seriesLogLevel = getLogLevelFromKey(labels['level']);
- }
for (let j = 0; j < series.length; j++) {
const ts = timeField.values.get(j);
@@ -386,14 +409,20 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi
const searchWords = series.meta && series.meta.searchWords ? series.meta.searchWords : [];
const entry = hasAnsi ? ansicolor.strip(message) : message;
+ const labels = getLabelsForFrameRow(info, j);
+ const uniqueLabels = findUniqueLabels(labels, commonLabels);
+ if (Object.keys(uniqueLabels).length > 0) {
+ hasUniqueLabels = true;
+ }
+
let logLevel = LogLevel.unknown;
- if (logLevelField && logLevelField.values.get(j)) {
- logLevel = getLogLevelFromKey(logLevelField.values.get(j));
- } else if (seriesLogLevel) {
- logLevel = seriesLogLevel;
+ const logLevelKey = (logLevelField && logLevelField.values.get(j)) || (labels && labels['level']);
+ if (logLevelKey) {
+ logLevel = getLogLevelFromKey(logLevelKey);
} else {
logLevel = getLogLevel(entry);
}
+
rows.push({
entryFieldIndex: stringField.index,
rowIndex: j,
@@ -410,7 +439,7 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi
searchWords,
entry,
raw: message,
- labels: stringField.labels || {},
+ labels: labels || {},
uid: idField ? idField.values.get(j) : j.toString(),
});
}
diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx
index b7a90c80d4a..ea93183aa40 100644
--- a/public/app/features/alerting/routes.tsx
+++ b/public/app/features/alerting/routes.tsx
@@ -6,6 +6,8 @@ import { RouteDescriptor } from 'app/core/navigation/types';
import { uniq } from 'lodash';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
+import { OrgRole } from '@grafana/data';
+import { evaluateAccess } from './unified/utils/access-control';
const commonRoutes: RouteDescriptor[] = [
{
@@ -89,96 +91,138 @@ const unifiedRoutes: RouteDescriptor[] = [
...commonRoutes,
{
path: '/alerting/list',
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead],
+ [OrgRole.Viewer, OrgRole.Editor, OrgRole.Admin]
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertRuleListIndex" */ 'app/features/alerting/unified/RuleList')
),
},
{
path: '/alerting/routes',
- roles: () => ['Admin', 'Editor'],
+ roles: () =>
+ contextSrv.evaluatePermission(config.unifiedAlertingEnabled ? () => ['Editor', 'Admin'] : () => [], [
+ AccessControlAction.AlertingNotificationsRead,
+ AccessControlAction.AlertingNotificationsExternalRead,
+ ]),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertAmRoutes" */ 'app/features/alerting/unified/AmRoutes')
),
},
{
path: '/alerting/routes/mute-timing/new',
- roles: () => ['Admin', 'Editor'],
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingNotificationsCreate, AccessControlAction.AlertingNotificationsExternalWrite],
+ ['Editor', 'Admin']
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "MuteTimings" */ 'app/features/alerting/unified/MuteTimings')
),
},
{
path: '/alerting/routes/mute-timing/edit',
- roles: () => ['Admin', 'Editor'],
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingNotificationsUpdate, AccessControlAction.AlertingNotificationsExternalWrite],
+ ['Editor', 'Admin']
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "MuteTimings" */ 'app/features/alerting/unified/MuteTimings')
),
},
{
path: '/alerting/silences',
- roles: () => contextSrv.evaluatePermission(() => [], [AccessControlAction.AlertingInstanceRead]),
+ roles: evaluateAccess([AccessControlAction.AlertingInstanceRead], ['Editor', 'Admin']),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
),
},
{
path: '/alerting/silence/new',
- roles: () => contextSrv.evaluatePermission(() => ['Editor', 'Admin'], [AccessControlAction.AlertingInstanceCreate]),
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingInstanceCreate, AccessControlAction.AlertingInstancesExternalWrite],
+ ['Editor', 'Admin']
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
),
},
{
path: '/alerting/silence/:id/edit',
- roles: () => contextSrv.evaluatePermission(() => ['Editor', 'Admin'], [AccessControlAction.AlertingInstanceUpdate]),
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingInstanceUpdate, AccessControlAction.AlertingInstancesExternalWrite],
+ ['Editor', 'Admin']
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
),
},
{
path: '/alerting/notifications',
- roles: config.unifiedAlertingEnabled ? () => ['Editor', 'Admin'] : undefined,
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsExternalRead],
+ ['Editor', 'Admin']
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{
path: '/alerting/notifications/templates/new',
- roles: () => ['Editor', 'Admin'],
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingNotificationsCreate, AccessControlAction.AlertingNotificationsExternalWrite],
+ ['Editor', 'Admin']
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{
path: '/alerting/notifications/templates/:id/edit',
- roles: () => ['Editor', 'Admin'],
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingNotificationsUpdate, AccessControlAction.AlertingNotificationsExternalWrite],
+ ['Editor', 'Admin']
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{
path: '/alerting/notifications/receivers/new',
- roles: () => ['Editor', 'Admin'],
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingNotificationsCreate, AccessControlAction.AlertingNotificationsExternalWrite],
+ ['Editor', 'Admin']
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{
path: '/alerting/notifications/receivers/:id/edit',
- roles: () => ['Editor', 'Admin'],
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingNotificationsUpdate, AccessControlAction.AlertingNotificationsExternalWrite],
+ ['Editor', 'Admin']
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{
path: '/alerting/notifications/global-config',
- roles: () => ['Admin', 'Editor'],
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingNotificationsUpdate, AccessControlAction.AlertingNotificationsExternalWrite],
+ ['Editor', 'Admin']
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{
path: '/alerting/groups/',
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingInstanceRead, AccessControlAction.AlertingInstancesExternalRead],
+ [OrgRole.Viewer, OrgRole.Editor, OrgRole.Admin]
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertGroups" */ 'app/features/alerting/unified/AlertGroups')
),
@@ -186,6 +230,10 @@ const unifiedRoutes: RouteDescriptor[] = [
{
path: '/alerting/new',
pageClass: 'page-alerting',
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite],
+ [OrgRole.Editor, OrgRole.Admin]
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor')
),
@@ -193,6 +241,10 @@ const unifiedRoutes: RouteDescriptor[] = [
{
path: '/alerting/:id/edit',
pageClass: 'page-alerting',
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleExternalWrite],
+ [OrgRole.Editor, OrgRole.Admin]
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor')
),
@@ -200,6 +252,10 @@ const unifiedRoutes: RouteDescriptor[] = [
{
path: '/alerting/:sourceName/:id/view',
pageClass: 'page-alerting',
+ roles: evaluateAccess(
+ [AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead],
+ [OrgRole.Viewer, OrgRole.Editor, OrgRole.Admin]
+ ),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertingRule"*/ 'app/features/alerting/unified/RuleViewer')
),
diff --git a/public/app/features/alerting/unified/AlertGroups.test.tsx b/public/app/features/alerting/unified/AlertGroups.test.tsx
index ec9704c7ede..765af678612 100644
--- a/public/app/features/alerting/unified/AlertGroups.test.tsx
+++ b/public/app/features/alerting/unified/AlertGroups.test.tsx
@@ -16,6 +16,7 @@ jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
isEditor: true,
hasAccess: () => true,
+ hasPermission: () => true,
},
}));
const mocks = {
diff --git a/public/app/features/alerting/unified/AmRoutes.test.tsx b/public/app/features/alerting/unified/AmRoutes.test.tsx
index 01f339bbf66..85a561165ba 100644
--- a/public/app/features/alerting/unified/AmRoutes.test.tsx
+++ b/public/app/features/alerting/unified/AmRoutes.test.tsx
@@ -20,9 +20,12 @@ import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import userEvent from '@testing-library/user-event';
import { selectOptionInTest } from '@grafana/ui';
import { ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
+import { contextSrv } from 'app/core/services/context_srv';
+import { AccessControlAction } from 'app/types';
jest.mock('./api/alertmanager');
jest.mock('./utils/config');
+jest.mock('app/core/services/context_srv');
const mocks = {
getAllDataSourcesMock: jest.mocked(getAllDataSources),
@@ -32,6 +35,7 @@ const mocks = {
updateAlertManagerConfig: jest.mocked(updateAlertManagerConfig),
fetchStatus: jest.mocked(fetchStatus),
},
+ contextSrv: jest.mocked(contextSrv),
};
const renderAmRoutes = (alertManagerSourceName?: string) => {
@@ -177,6 +181,9 @@ describe('AmRoutes', () => {
beforeEach(() => {
mocks.getAllDataSourcesMock.mockReturnValue(Object.values(dataSources));
+ mocks.contextSrv.hasAccess.mockImplementation(() => true);
+ mocks.contextSrv.hasPermission.mockImplementation(() => true);
+ mocks.contextSrv.evaluatePermission.mockImplementation(() => []);
setDataSourceSrv(new MockDataSourceSrv(dataSources));
});
@@ -359,6 +366,18 @@ describe('AmRoutes', () => {
});
});
+ it('hides create and edit button if user does not have permission', () => {
+ mocks.contextSrv.hasAccess.mockImplementation((action) =>
+ [AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsRead].includes(
+ action as AccessControlAction
+ )
+ );
+
+ renderAmRoutes();
+ expect(ui.newPolicyButton.query()).not.toBeInTheDocument();
+ expect(ui.editButton.query()).not.toBeInTheDocument();
+ });
+
it('Show error message if loading Alertmanager config fails', async () => {
mocks.api.fetchAlertManagerConfig.mockRejectedValue({
status: 500,
diff --git a/public/app/features/alerting/unified/AmRoutes.tsx b/public/app/features/alerting/unified/AmRoutes.tsx
index 43c09b1d4e2..8bcdaa7e184 100644
--- a/public/app/features/alerting/unified/AmRoutes.tsx
+++ b/public/app/features/alerting/unified/AmRoutes.tsx
@@ -122,6 +122,7 @@ const AmRoutes: FC = () => {
/>
{
@@ -125,8 +128,23 @@ describe('Receivers', () => {
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.fetchNotifiers.mockResolvedValue(grafanaNotifiersMock);
setDataSourceSrv(new MockDataSourceSrv(dataSources));
- contextSrv.isEditor = true;
+ mocks.contextSrv.isEditor = true;
store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY);
+
+ mocks.contextSrv.evaluatePermission.mockImplementation(() => []);
+ mocks.contextSrv.hasPermission.mockImplementation((action) => {
+ const permissions = [
+ AccessControlAction.AlertingNotificationsRead,
+ AccessControlAction.AlertingNotificationsCreate,
+ AccessControlAction.AlertingNotificationsUpdate,
+ AccessControlAction.AlertingNotificationsDelete,
+ AccessControlAction.AlertingNotificationsExternalRead,
+ AccessControlAction.AlertingNotificationsExternalWrite,
+ ];
+ return permissions.includes(action as AccessControlAction);
+ });
+
+ mocks.contextSrv.hasAccess.mockImplementation(() => true);
});
it('Template and receiver tables are rendered, alertmanager can be selected', async () => {
@@ -295,6 +313,19 @@ describe('Receivers', () => {
});
});
+ it('Hides create contact point button for users without permission', () => {
+ mocks.api.fetchConfig.mockResolvedValue(someGrafanaAlertManagerConfig);
+ mocks.api.updateConfig.mockResolvedValue();
+ mocks.contextSrv.hasAccess.mockImplementation((action) =>
+ [AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsExternalRead].some(
+ (a) => a === action
+ )
+ );
+ renderReceivers();
+
+ expect(ui.newContactPointButton.query()).not.toBeInTheDocument();
+ });
+
it('Cloud alertmanager receiver can be edited', async () => {
mocks.api.fetchConfig.mockResolvedValue(someCloudAlertManagerConfig);
mocks.api.updateConfig.mockResolvedValue();
diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx
index 02bee4161d8..cf503117041 100644
--- a/public/app/features/alerting/unified/Receivers.tsx
+++ b/public/app/features/alerting/unified/Receivers.tsx
@@ -41,7 +41,10 @@ const Receivers: FC = () => {
}, [alertManagerSourceName, dispatch, shouldLoadConfig]);
useEffect(() => {
- if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME && !(receiverTypes.result || receiverTypes.loading)) {
+ if (
+ alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME &&
+ !(receiverTypes.result || receiverTypes.loading || receiverTypes.error)
+ ) {
dispatch(fetchGrafanaNotifiersAction());
}
}, [alertManagerSourceName, dispatch, receiverTypes]);
diff --git a/public/app/features/alerting/unified/components/CollapseToggle.test.tsx b/public/app/features/alerting/unified/components/CollapseToggle.test.tsx
new file mode 100644
index 00000000000..384da5879fd
--- /dev/null
+++ b/public/app/features/alerting/unified/components/CollapseToggle.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@testing-library/react';
+import { noop } from 'lodash';
+import React from 'react';
+import { CollapseToggle } from './CollapseToggle';
+
+describe('TestToggle', () => {
+ it('should render text', () => {
+ render( );
+ expect(screen.getByRole('button')).toHaveTextContent('Hello, world');
+ });
+
+ it('should respect isCollapsed', () => {
+ const { rerender } = render( );
+ expect(screen.getByRole('button', { expanded: true })).toBeInTheDocument();
+
+ rerender( );
+ expect(screen.getByRole('button', { expanded: false })).toBeInTheDocument();
+ });
+
+ it('should call onToggle', () => {
+ const onToggle = jest.fn();
+ render( );
+ screen.getByRole('button').click();
+
+ expect(onToggle).toHaveBeenCalledWith(false);
+ // it should also not have any impact on the actual expanded state since the component does not track its own state
+ expect(screen.getByRole('button', { expanded: false })).toBeInTheDocument();
+ });
+});
diff --git a/public/app/features/alerting/unified/components/CollapseToggle.tsx b/public/app/features/alerting/unified/components/CollapseToggle.tsx
index c63256e077f..3e670a396be 100644
--- a/public/app/features/alerting/unified/components/CollapseToggle.tsx
+++ b/public/app/features/alerting/unified/components/CollapseToggle.tsx
@@ -34,7 +34,9 @@ export const CollapseToggle: FC = ({
icon={isCollapsed ? 'angle-right' : 'angle-down'}
onClick={() => onToggle(!isCollapsed)}
{...restOfProps}
- />
+ >
+ {text}
+
);
};
diff --git a/public/app/features/alerting/unified/components/EmptyAreaWithCTA.tsx b/public/app/features/alerting/unified/components/EmptyAreaWithCTA.tsx
index c507512ab07..a1c8d834104 100644
--- a/public/app/features/alerting/unified/components/EmptyAreaWithCTA.tsx
+++ b/public/app/features/alerting/unified/components/EmptyAreaWithCTA.tsx
@@ -13,6 +13,7 @@ export interface EmptyAreaWithCTAProps {
buttonIcon?: IconName;
buttonSize?: 'xs' | 'sm' | 'md' | 'lg';
buttonVariant?: ButtonVariant;
+ showButton?: boolean;
}
export const EmptyAreaWithCTA: FC = ({
@@ -23,6 +24,7 @@ export const EmptyAreaWithCTA: FC = ({
onButtonClick,
text,
href,
+ showButton = true,
}) => {
const styles = useStyles(getStyles);
@@ -37,15 +39,16 @@ export const EmptyAreaWithCTA: FC = ({
<>
{text}
- {href ? (
-
- {buttonLabel}
-
- ) : (
-
- {buttonLabel}
-
- )}
+ {showButton &&
+ (href ? (
+
+ {buttonLabel}
+
+ ) : (
+
+ {buttonLabel}
+
+ ))}
>
);
diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx
index e7867add2ba..878620f5fd5 100644
--- a/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx
+++ b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx
@@ -5,6 +5,7 @@ import { contextSrv } from 'app/core/services/context_srv';
import { AlertmanagerAlert, AlertState } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import React, { FC } from 'react';
+import { getInstancesPermissions } from '../../utils/access-control';
import { isGrafanaRulesSource } from '../../utils/datasource';
import { makeAMLink, makeLabelBasedSilenceLink } from '../../utils/misc';
import { AnnotationDetailsField } from '../AnnotationDetailsField';
@@ -17,18 +18,19 @@ interface AmNotificationsAlertDetailsProps {
export const AlertDetails: FC = ({ alert, alertManagerSourceName }) => {
const styles = useStyles2(getStyles);
- const isExternalAM = !isGrafanaRulesSource(alertManagerSourceName);
+ const instancePermissions = getInstancesPermissions(alertManagerSourceName);
+
+ // For Grafana Managed alerts the Generator URL redirects to the alert rule edit page, so update permission is required
+ // For external alert manager the Generator URL redirects to an external service which we don't control
+ const isGrafanaSource = isGrafanaRulesSource(alertManagerSourceName);
+ const isSeeSourceButtonEnabled = isGrafanaSource
+ ? contextSrv.hasPermission(AccessControlAction.AlertingRuleUpdate)
+ : true;
+
return (
<>
-
+
{alert.status.state === AlertState.Suppressed && (
= ({ alert, aler
)}
-
- {alert.generatorURL && (
-
- See source
-
- )}
-
+ {isSeeSourceButtonEnabled && alert.generatorURL && (
+
+ See source
+
+ )}
{Object.entries(alert.annotations).map(([annotationKey, annotationValue]) => (
diff --git a/public/app/features/alerting/unified/components/amroutes/AmRootRoute.tsx b/public/app/features/alerting/unified/components/amroutes/AmRootRoute.tsx
index e248d4e2609..e3c1a7d7299 100644
--- a/public/app/features/alerting/unified/components/amroutes/AmRootRoute.tsx
+++ b/public/app/features/alerting/unified/components/amroutes/AmRootRoute.tsx
@@ -6,7 +6,8 @@ import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes';
import { AmRootRouteForm } from './AmRootRouteForm';
import { AmRootRouteRead } from './AmRootRouteRead';
import { isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
-
+import { Authorize } from '../../components/Authorize';
+import { getNotificationsPermissions } from '../../utils/access-control';
export interface AmRootRouteProps {
isEditMode: boolean;
onEnterEditMode: () => void;
@@ -28,6 +29,7 @@ export const AmRootRoute: FC = ({
}) => {
const styles = useStyles2(getStyles);
+ const permissions = getNotificationsPermissions(alertManagerSourceName);
const isReadOnly = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
return (
@@ -37,9 +39,11 @@ export const AmRootRoute: FC = ({
Root policy - default for all alerts
{!isEditMode && !isReadOnly && (
-
- Edit
-
+
+
+ Edit
+
+
)}
diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedRead.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedRead.tsx
index 04d3431e5b2..1ea268ae584 100644
--- a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedRead.tsx
+++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedRead.tsx
@@ -7,13 +7,15 @@ import { emptyRoute } from '../../utils/amroutes';
import { AmRoutesTable } from './AmRoutesTable';
import { getGridStyles } from './gridStyles';
import { MuteTimingsTable } from './MuteTimingsTable';
-import { useAlertManagerSourceName } from '../../hooks/useAlertManagerSourceName';
+import { Authorize } from '../Authorize';
+import { getNotificationsPermissions } from '../../utils/access-control';
export interface AmRoutesExpandedReadProps {
onChange: (routes: FormAmRoute) => void;
receivers: AmRouteReceiver[];
routes: FormAmRoute;
readOnly?: boolean;
+ alertManagerSourceName: string;
}
export const AmRoutesExpandedRead: FC = ({
@@ -21,10 +23,11 @@ export const AmRoutesExpandedRead: FC = ({
receivers,
routes,
readOnly = false,
+ alertManagerSourceName,
}) => {
const styles = useStyles2(getStyles);
const gridStyles = useStyles2(getGridStyles);
- const [alertManagerSourceName] = useAlertManagerSourceName();
+ const permissions = getNotificationsPermissions(alertManagerSourceName);
const groupWait = routes.groupWaitValue ? `${routes.groupWaitValue}${routes.groupWaitValueType}` : '-';
const groupInterval = routes.groupIntervalValue
@@ -71,23 +74,26 @@ export const AmRoutesExpandedRead: FC = ({
}}
receivers={receivers}
routes={subroutes}
+ alertManagerSourceName={alertManagerSourceName}
/>
) : (
No nested policies configured.
)}
{!isAddMode && !readOnly && (
- {
- setSubroutes((subroutes) => [...subroutes, emptyRoute]);
- setIsAddMode(true);
- }}
- variant="secondary"
- type="button"
- >
- Add nested policy
-
+
+ {
+ setSubroutes((subroutes) => [...subroutes, emptyRoute]);
+ setIsAddMode(true);
+ }}
+ variant="secondary"
+ type="button"
+ >
+ Add nested policy
+
+
)}
Mute timings
diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx
index e5cb1df5d46..c25f812d57a 100644
--- a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx
+++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx
@@ -9,6 +9,8 @@ import { Matchers } from '../silences/Matchers';
import { matcherFieldToMatcher, parseMatchers } from '../../utils/alertmanager';
import { intersectionWith, isEqual } from 'lodash';
import { EmptyArea } from '../EmptyArea';
+import { contextSrv } from 'app/core/services/context_srv';
+import { getNotificationsPermissions } from '../../utils/access-control';
export interface AmRoutesTableProps {
isAddMode: boolean;
@@ -18,6 +20,7 @@ export interface AmRoutesTableProps {
routes: FormAmRoute[];
filters?: { queryString?: string; contactPoint?: string };
readOnly?: boolean;
+ alertManagerSourceName: string;
}
type RouteTableColumnProps = DynamicTableColumnProps;
@@ -69,9 +72,15 @@ export const AmRoutesTable: FC = ({
routes,
filters,
readOnly = false,
+ alertManagerSourceName,
}) => {
const [editMode, setEditMode] = useState(false);
const [expandedId, setExpandedId] = useState();
+ const permissions = getNotificationsPermissions(alertManagerSourceName);
+ const canEditRoutes = contextSrv.hasPermission(permissions.update);
+ const canDeleteRoutes = contextSrv.hasPermission(permissions.delete);
+
+ const showActions = !readOnly && (canEditRoutes || canDeleteRoutes);
const expandItem = useCallback((item: RouteTableItemProps) => setExpandedId(item.id), []);
const collapseItem = useCallback(() => setExpandedId(undefined), []);
@@ -102,7 +111,7 @@ export const AmRoutesTable: FC = ({
renderCell: (item) => item.data.muteTimeIntervals.join(', ') || '-',
size: 5,
},
- ...(readOnly
+ ...(!showActions
? []
: [
{
@@ -212,6 +221,7 @@ export const AmRoutesTable: FC = ({
receivers={receivers}
routes={item.data}
readOnly={readOnly}
+ alertManagerSourceName={alertManagerSourceName}
/>
)
}
diff --git a/public/app/features/alerting/unified/components/amroutes/AmSpecificRouting.tsx b/public/app/features/alerting/unified/components/amroutes/AmSpecificRouting.tsx
index c2acff52421..7fba03bfc12 100644
--- a/public/app/features/alerting/unified/components/amroutes/AmSpecificRouting.tsx
+++ b/public/app/features/alerting/unified/components/amroutes/AmSpecificRouting.tsx
@@ -11,8 +11,12 @@ import { MatcherFilter } from '../alert-groups/MatcherFilter';
import { EmptyArea } from '../EmptyArea';
import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA';
import { AmRoutesTable } from './AmRoutesTable';
+import { Authorize } from '../../components/Authorize';
+import { contextSrv } from 'app/core/services/context_srv';
+import { getNotificationsPermissions } from '../../utils/access-control';
export interface AmSpecificRoutingProps {
+ alertManagerSourceName: string;
onChange: (routes: FormAmRoute) => void;
onRootRouteEdit: () => void;
receivers: AmRouteReceiver[];
@@ -26,6 +30,7 @@ interface Filters {
}
export const AmSpecificRouting: FC = ({
+ alertManagerSourceName,
onChange,
onRootRouteEdit,
receivers,
@@ -34,6 +39,8 @@ export const AmSpecificRouting: FC = ({
}) => {
const [actualRoutes, setActualRoutes] = useState([...routes.routes]);
const [isAddMode, setIsAddMode] = useState(false);
+ const permissions = getNotificationsPermissions(alertManagerSourceName);
+ const canCreateNotifications = contextSrv.hasPermission(permissions.create);
const [searchParams, setSearchParams] = useURLSearchParams();
const { queryString, contactPoint } = getNotificationPoliciesFilters(searchParams);
@@ -97,6 +104,7 @@ export const AmSpecificRouting: FC = ({
buttonLabel="Set a default contact point"
onButtonClick={onRootRouteEdit}
text="You haven't set a default contact point for the root route yet."
+ showButton={canCreateNotifications}
/>
)
) : actualRoutes.length > 0 ? (
@@ -132,11 +140,13 @@ export const AmSpecificRouting: FC = ({
)}
{!isAddMode && !readOnly && (
-
-
- New policy
-
-
+
+
+
+ New policy
+
+
+
)}
= ({
receivers={receivers}
routes={actualRoutes}
filters={{ queryString, contactPoint }}
+ alertManagerSourceName={alertManagerSourceName}
/>
>
) : readOnly ? (
@@ -159,6 +170,7 @@ export const AmSpecificRouting: FC = ({
buttonLabel="New specific policy"
onButtonClick={addNewRoute}
text="You haven't created any specific policies yet."
+ showButton={canCreateNotifications}
/>
)}
diff --git a/public/app/features/alerting/unified/components/amroutes/MuteTimingsTable.tsx b/public/app/features/alerting/unified/components/amroutes/MuteTimingsTable.tsx
index 7ee43ea5b43..04497c62d28 100644
--- a/public/app/features/alerting/unified/components/amroutes/MuteTimingsTable.tsx
+++ b/public/app/features/alerting/unified/components/amroutes/MuteTimingsTable.tsx
@@ -17,6 +17,9 @@ import {
getYearsString,
} from '../../utils/alertmanager';
import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA';
+import { Authorize } from '../../components/Authorize';
+import { contextSrv } from 'app/core/services/context_srv';
+import { getNotificationsPermissions } from '../../utils/access-control';
interface Props {
alertManagerSourceName: string;
@@ -27,6 +30,7 @@ interface Props {
export const MuteTimingsTable: FC = ({ alertManagerSourceName, muteTimingNames, hideActions }) => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
+ const permissions = getNotificationsPermissions(alertManagerSourceName);
const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs);
const [muteTimingName, setMuteTimingName] = useState('');
const { result }: AsyncRequestState =
@@ -56,14 +60,16 @@ export const MuteTimingsTable: FC = ({ alertManagerSourceName, muteTiming
)}
{!hideActions && items.length > 0 && (
-
- New mute timing
-
+
+
+ New mute timing
+
+
)}
{items.length > 0 ? (
@@ -74,6 +80,7 @@ export const MuteTimingsTable: FC = ({ alertManagerSourceName, muteTiming
buttonIcon="plus"
buttonSize="lg"
href={makeAMLink('alerting/routes/mute-timing/new', alertManagerSourceName)}
+ showButton={contextSrv.hasPermission(permissions.create)}
/>
) : (
No mute timings configured
@@ -93,6 +100,11 @@ export const MuteTimingsTable: FC = ({ alertManagerSourceName, muteTiming
};
function useColumns(alertManagerSourceName: string, hideActions = false, setMuteTimingName: (name: string) => void) {
+ const permissions = getNotificationsPermissions(alertManagerSourceName);
+
+ const userHasEditPermissions = contextSrv.hasPermission(permissions.update);
+ const userHasDeletePermissions = contextSrv.hasPermission(permissions.delete);
+ const showActions = !hideActions && (userHasEditPermissions || userHasDeletePermissions);
return useMemo((): Array> => {
const columns: Array> = [
{
@@ -109,19 +121,29 @@ function useColumns(alertManagerSourceName: string, hideActions = false, setMute
renderCell: ({ data }) => renderTimeIntervals(data.time_intervals),
},
];
- if (!hideActions) {
+ if (showActions) {
columns.push({
id: 'actions',
label: 'Actions',
renderCell: function renderActions({ data }) {
return (
-
-
-
-
setMuteTimingName(data.name)} />
+
+
+
+
+
+
+ setMuteTimingName(data.name)}
+ />
+
);
},
@@ -129,7 +151,7 @@ function useColumns(alertManagerSourceName: string, hideActions = false, setMute
});
}
return columns;
- }, [alertManagerSourceName, hideActions, setMuteTimingName]);
+ }, [alertManagerSourceName, setMuteTimingName, showActions, permissions]);
}
function renderTimeIntervals(timeIntervals: TimeInterval[]) {
diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversAndTemplatesView.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversAndTemplatesView.tsx
index 7a03daa5322..439b851212d 100644
--- a/public/app/features/alerting/unified/components/receivers/ReceiversAndTemplatesView.tsx
+++ b/public/app/features/alerting/unified/components/receivers/ReceiversAndTemplatesView.tsx
@@ -2,9 +2,11 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, LinkButton, useStyles2 } from '@grafana/ui';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
+import { AccessControlAction } from 'app/types';
import React, { FC } from 'react';
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
import { makeAMLink } from '../../utils/misc';
+import { Authorize } from '../Authorize';
import { ReceiversTable } from './ReceiversTable';
import { TemplatesTable } from './TemplatesTable';
@@ -22,15 +24,17 @@ export const ReceiversAndTemplatesView: FC = ({ config, alertManagerName
{!isVanillaAM && }
{isCloud && (
-
-
- For each external Alertmanager you can define global settings, like server addresses, usernames and
- password, for all the supported contact points.
-
-
- {isVanillaAM ? 'View global config' : 'Edit global config'}
-
-
+
+
+
+ For each external Alertmanager you can define global settings, like server addresses, usernames and
+ password, for all the supported contact points.
+
+
+ {isVanillaAM ? 'View global config' : 'Edit global config'}
+
+
+
)}
>
);
diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx
index ba453192eb8..6a3b6761efa 100644
--- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx
+++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx
@@ -13,6 +13,9 @@ import { isReceiverUsed } from '../../utils/alertmanager';
import { useDispatch } from 'react-redux';
import { deleteReceiverAction } from '../../state/actions';
import { isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
+import { Authorize } from '../../components/Authorize';
+import { contextSrv } from 'app/core/services/context_srv';
+import { getNotificationsPermissions } from '../../utils/access-control';
interface Props {
config: AlertManagerCortexConfig;
@@ -24,6 +27,7 @@ export const ReceiversTable: FC = ({ config, alertManagerName }) => {
const tableStyles = useStyles2(getAlertTableStyles);
const styles = useStyles2(getStyles);
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerName);
+ const permissions = getNotificationsPermissions(alertManagerName);
const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers);
// receiver name slated for deletion. If this is set, a confirmation modal is shown. If user approves, this receiver is deleted
@@ -66,7 +70,7 @@ export const ReceiversTable: FC = ({ config, alertManagerName }) => {
className={styles.section}
title="Contact points"
description="Define where the notifications will be sent to, for example email or Slack."
- showButton={!isVanillaAM}
+ showButton={!isVanillaAM && contextSrv.hasPermission(permissions.create)}
addButtonLabel="New contact point"
addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)}
>
@@ -74,13 +78,17 @@ export const ReceiversTable: FC = ({ config, alertManagerName }) => {
-
+
+
+
Contact point name
Type
- Actions
+
+ Actions
+
@@ -93,38 +101,46 @@ export const ReceiversTable: FC = ({ config, alertManagerName }) => {
{receiver.name}
{receiver.types.join(', ')}
-
- {!isVanillaAM && (
- <>
-
- onClickDeleteReceiver(receiver.name)}
- tooltip="Delete contact point"
- icon="trash-alt"
- />
- >
- )}
- {isVanillaAM && (
-
- )}
-
+
+
+ {!isVanillaAM && (
+ <>
+
+
+
+
+ onClickDeleteReceiver(receiver.name)}
+ tooltip="Delete contact point"
+ icon="trash-alt"
+ />
+
+ >
+ )}
+ {isVanillaAM && (
+
+
+
+ )}
+
+
))}
diff --git a/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx b/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx
index 48f9d83b7a3..b661c350a98 100644
--- a/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx
+++ b/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx
@@ -9,6 +9,9 @@ import { ReceiversSection } from './ReceiversSection';
import { makeAMLink } from '../../utils/misc';
import { useDispatch } from 'react-redux';
import { deleteTemplateAction } from '../../state/actions';
+import { contextSrv } from 'app/core/services/context_srv';
+import { Authorize } from '../../components/Authorize';
+import { getNotificationsPermissions } from '../../utils/access-control';
interface Props {
config: AlertManagerCortexConfig;
@@ -19,6 +22,7 @@ export const TemplatesTable: FC = ({ config, alertManagerName }) => {
const dispatch = useDispatch();
const [expandedTemplates, setExpandedTemplates] = useState>({});
const tableStyles = useStyles2(getAlertTableStyles);
+ const permissions = getNotificationsPermissions(alertManagerName);
const templateRows = useMemo(() => Object.entries(config.template_files), [config]);
const [templateToDelete, setTemplateToDelete] = useState();
@@ -36,6 +40,7 @@ export const TemplatesTable: FC = ({ config, alertManagerName }) => {
description="Templates construct the messages that get sent to the contact points."
addButtonLabel="New template"
addButtonTo={makeAMLink('/alerting/notifications/templates/new', alertManagerName)}
+ showButton={contextSrv.hasPermission(permissions.create)}
>
@@ -47,7 +52,9 @@ export const TemplatesTable: FC = ({ config, alertManagerName }) => {
Template
- Actions
+
+ Actions
+
@@ -68,17 +75,27 @@ export const TemplatesTable: FC = ({ config, alertManagerName }) => {
/>
{name}
-
-
- setTemplateToDelete(name)} tooltip="delete template" icon="trash-alt" />
-
+
+
+
+
+
+
+ setTemplateToDelete(name)}
+ tooltip="delete template"
+ icon="trash-alt"
+ />
+
+
+
{isExpanded && (
diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx
index aa2179f2265..b06e7e2497c 100644
--- a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx
@@ -38,27 +38,24 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => {
return (
-
- (
-
- )}
- name="type"
- control={control}
- rules={{
- required: { value: true, message: 'Please select alert type' },
- }}
- />
-
+ {!editingExistingRule && (
+
+ (
+
+ )}
+ name="type"
+ control={control}
+ rules={{
+ required: { value: true, message: 'Please select alert type' },
+ }}
+ />
+
+ )}
) : null}
- {!isExpressionQuery(query.model) && (
- <>
-
-
- View in Explore
-
- >
- )}
+
+ {!isExpressionQuery(query.model) && (
+ <>
+
+
+ View in Explore
+
+ >
+ )}
+
{
+ mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
+
+ describe('Grafana rules action buttons', () => {
+ const grafanaRule = getGrafanaRule({ name: 'Grafana' });
+ it('Should not render Edit button for users without the update permission', () => {
+ // Arrange
+ jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
+
+ // Act
+ renderRuleDetails(grafanaRule);
+
+ // Assert
+ expect(ui.actionButtons.edit.query()).not.toBeInTheDocument();
+ });
+
+ it('Should not render Delete button for users without the delete permission', () => {
+ // Arrange
+ jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
+
+ // Act
+ renderRuleDetails(grafanaRule);
+
+ // Assert
+ expect(ui.actionButtons.delete.query()).not.toBeInTheDocument();
+ });
+
+ it('Should render Edit button for users with the update permission', () => {
+ // Arrange
+ jest
+ .spyOn(contextSrv, 'hasPermission')
+ .mockImplementation((action) => action === AccessControlAction.AlertingRuleUpdate);
+
+ // Act
+ renderRuleDetails(grafanaRule);
+
+ // Assert
+ expect(ui.actionButtons.edit.query()).toBeInTheDocument();
+ });
+
+ it('Should render Delete button for users with the delete permission', () => {
+ // Arrange
+ jest
+ .spyOn(contextSrv, 'hasPermission')
+ .mockImplementation((action) => action === AccessControlAction.AlertingRuleDelete);
+
+ // Act
+ renderRuleDetails(grafanaRule);
+
+ // Assert
+ expect(ui.actionButtons.delete.query()).toBeInTheDocument();
+ });
+
+ it('Should not render Silence button for users wihout the instance create permission', () => {
+ // Arrange
+ jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
+
+ // Act
+ renderRuleDetails(grafanaRule);
+
+ // Assert
+ expect(ui.actionButtons.silence.query()).not.toBeInTheDocument();
+ });
+
+ it('Should render Silence button for users with the instance create permissions', () => {
+ // Arrange
+ jest
+ .spyOn(contextSrv, 'hasPermission')
+ .mockImplementation((action) => action === AccessControlAction.AlertingInstanceCreate);
+
+ // Act
+ renderRuleDetails(grafanaRule);
+
+ // Assert
+ expect(ui.actionButtons.silence.query()).toBeInTheDocument();
+ });
+ });
+
+ describe('Cloud rules action buttons', () => {
+ const cloudRule = getCloudRule({ name: 'Cloud' });
+ it('Should not render Edit button for users without the update permission', () => {
+ // Arrange
+ jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
+
+ // Act
+ renderRuleDetails(cloudRule);
+
+ // Assert
+ expect(ui.actionButtons.edit.query()).not.toBeInTheDocument();
+ });
+
+ it('Should not render Delete button for users without the delete permission', () => {
+ // Arrange
+ jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
+
+ // Act
+ renderRuleDetails(cloudRule);
+
+ // Assert
+ expect(ui.actionButtons.delete.query()).not.toBeInTheDocument();
+ });
+
+ it('Should render Edit button for users with the update permission', () => {
+ // Arrange
+ jest
+ .spyOn(contextSrv, 'hasPermission')
+ .mockImplementation((action) => action === AccessControlAction.AlertingRuleExternalWrite);
+
+ // Act
+ renderRuleDetails(cloudRule);
+
+ // Assert
+ expect(ui.actionButtons.edit.query()).toBeInTheDocument();
+ });
+
+ it('Should render Delete button for users with the delete permission', () => {
+ // Arrange
+ jest
+ .spyOn(contextSrv, 'hasPermission')
+ .mockImplementation((action) => action === AccessControlAction.AlertingRuleExternalWrite);
+
+ // Act
+ renderRuleDetails(cloudRule);
+
+ // Assert
+ expect(ui.actionButtons.delete.query()).toBeInTheDocument();
+ });
+ });
+});
+
+function renderRuleDetails(rule: CombinedRule) {
+ const store = configureStore();
+
+ render(
+
+
+
+
+
+ );
+}
+
+function getGrafanaRule(override?: Partial) {
+ return mockCombinedRule({
+ namespace: {
+ groups: [],
+ name: 'Grafana',
+ rulesSource: 'grafana',
+ },
+ ...override,
+ });
+}
+
+function getCloudRule(override?: Partial) {
+ return mockCombinedRule({
+ namespace: {
+ groups: [],
+ name: 'Cortex',
+ rulesSource: mockDataSource(),
+ },
+ promRule: mockPromAlertingRule(),
+ rulerRule: mockRulerAlertingRule(),
+ ...override,
+ });
+}
diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx
index af21b4d4ab2..db8a5fc8e8c 100644
--- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx
+++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx
@@ -1,15 +1,15 @@
-import { CombinedRule } from 'app/types/unified-alerting';
-import React, { FC } from 'react';
-import { useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2 } from '@grafana/ui';
+import { CombinedRule } from 'app/types/unified-alerting';
+import React, { FC } from 'react';
import { AlertLabels } from '../AlertLabels';
import { DetailsField } from '../DetailsField';
import { RuleDetailsActionButtons } from './RuleDetailsActionButtons';
-import { RuleDetailsDataSources } from './RuleDetailsDataSources';
-import { RuleDetailsMatchingInstances } from './RuleDetailsMatchingInstances';
-import { RuleDetailsExpression } from './RuleDetailsExpression';
import { RuleDetailsAnnotations } from './RuleDetailsAnnotations';
+import { RuleDetailsDataSources } from './RuleDetailsDataSources';
+import { RuleDetailsExpression } from './RuleDetailsExpression';
+import { RuleDetailsMatchingInstances } from './RuleDetailsMatchingInstances';
interface Props {
rule: CombinedRule;
diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx
index 3e6640a24ef..7f26d28ba87 100644
--- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx
+++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx
@@ -1,24 +1,25 @@
-import React, { FC, Fragment, useState } from 'react';
-import { useDispatch } from 'react-redux';
-import { useLocation } from 'react-router-dom';
import { css } from '@emotion/css';
import { GrafanaTheme2, urlUtil } from '@grafana/data';
import { config } from '@grafana/runtime';
-import { Button, ConfirmModal, ClipboardButton, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui';
-import { contextSrv } from 'app/core/services/context_srv';
+import { Button, ClipboardButton, ConfirmModal, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
+import { contextSrv } from 'app/core/services/context_srv';
+import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control';
+import { AccessControlAction } from 'app/types';
+import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
+import { RulerGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
+import React, { FC, Fragment, useState } from 'react';
+import { useDispatch } from 'react-redux';
+import { useLocation } from 'react-router-dom';
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
+import { useStateHistoryModal } from '../../hooks/useStateHistoryModal';
+import { deleteRuleAction } from '../../state/actions';
+import { getAlertmanagerByUid } from '../../utils/alertmanager';
import { Annotation } from '../../utils/constants';
import { getRulesSourceName, isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource';
import { createExploreLink, createViewLink, makeRuleBasedSilenceLink } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
-import { deleteRuleAction } from '../../state/actions';
-import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
-import { getAlertmanagerByUid } from '../../utils/alertmanager';
-import { useStateHistoryModal } from '../../hooks/useStateHistoryModal';
-import { RulerGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { isFederatedRuleGroup } from '../../utils/rules';
-import { AccessControlAction } from 'app/types';
interface Props {
rule: CombinedRule;
@@ -38,12 +39,18 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => {
const alertmanagerSourceName = isGrafanaRulesSource(rulesSource)
? rulesSource
: getAlertmanagerByUid(rulesSource.jsonData.alertmanagerUid)?.name;
+ const rulesSourceName = getRulesSourceName(rulesSource);
+
+ const rulesPermissions = getRulesPermissions(rulesSourceName);
+ const hasEditPermission = contextSrv.hasPermission(rulesPermissions.update);
+ const hasDeletePermission = contextSrv.hasPermission(rulesPermissions.delete);
+ const hasExplorePermission = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore);
const leftButtons: JSX.Element[] = [];
const rightButtons: JSX.Element[] = [];
const isFederated = isFederatedRuleGroup(group);
- const { isEditable } = useIsRuleEditable(getRulesSourceName(rulesSource), rulerRule);
+ const { isEditable } = useIsRuleEditable(rulesSourceName, rulerRule);
const returnTo = location.pathname + location.search;
const isViewMode = inViewMode(location.pathname);
@@ -74,7 +81,7 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => {
// explore does not support grafana rule queries atm
// neither do "federated rules"
- if (isCloudRulesSource(rulesSource) && contextSrv.isEditor && !isFederated) {
+ if (isCloudRulesSource(rulesSource) && hasExplorePermission && !isFederated) {
leftButtons.push(
= ({ rule, rulesSource }) => {
);
}
+ // TODO Maybe there is a way to unify isEditable with FGAC permissions
if (isEditable && rulerRule && !isFederated) {
const sourceName = getRulesSourceName(rulesSource);
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
@@ -210,22 +218,28 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => {
);
}
- rightButtons.push(
-
- Edit
- ,
- setRuleToDelete(rule)}
- >
- Delete
-
- );
+ if (hasEditPermission) {
+ rightButtons.push(
+
+ Edit
+
+ );
+ }
+ if (hasDeletePermission) {
+ rightButtons.push(
+ setRuleToDelete(rule)}
+ >
+ Delete
+
+ );
+ }
}
if (leftButtons.length || rightButtons.length) {
return (
diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx
index b534a3e8592..ef668398c0c 100644
--- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx
+++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx
@@ -1,7 +1,9 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, ConfirmModal, HorizontalGroup, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui';
+import { contextSrv } from 'app/core/services/context_srv';
import kbn from 'app/core/utils/kbn';
+import { AccessControlAction } from 'app/types';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import pluralize from 'pluralize';
import React, { FC, useEffect, useState } from 'react';
@@ -32,6 +34,8 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll }
const [isDeletingGroup, setIsDeletingGroup] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(!expandAll);
+ const canEditCloudRules = contextSrv.hasPermission(AccessControlAction.AlertingRuleExternalWrite);
+
useEffect(() => {
setIsCollapsed(!expandAll);
}, [expandAll]);
@@ -88,7 +92,7 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll }
);
}
}
- } else if (hasRuler(rulesSource)) {
+ } else if (canEditCloudRules && hasRuler(rulesSource)) {
if (!isFederated) {
actionIcons.push(
= ({ silences, alertManagerAlerts, alertManagerSo
const styles = useStyles2(getStyles);
const [queryParams] = useQueryParams();
const filteredSilences = useFilteredSilences(silences);
- const isExternalAM = !isGrafanaRulesSource(alertManagerSourceName);
+ const permissions = getInstancesPermissions(alertManagerSourceName);
const { silenceState } = getSilenceFiltersFromUrlParams(queryParams);
@@ -65,14 +64,7 @@ const SilencesTable: FC = ({ silences, alertManagerAlerts, alertManagerSo
{!!silences.length && (
<>
-
+
@@ -178,15 +170,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
function useColumns(alertManagerSourceName: string) {
const dispatch = useDispatch();
const styles = useStyles2(getStyles);
- const isExternalAM = !isGrafanaRulesSource(alertManagerSourceName);
+ const permissions = getInstancesPermissions(alertManagerSourceName);
return useMemo((): SilenceTableColumnProps[] => {
const handleExpireSilenceClick = (id: string) => {
dispatch(expireSilenceAction(alertManagerSourceName, id));
};
- const showActions = contextSrv.hasAccess(
- isExternalAM ? AccessControlAction.AlertingInstancesExternalWrite : AccessControlAction.AlertingInstanceUpdate,
- contextSrv.isEditor
- );
+ const showActions = contextSrv.hasAccess(permissions.update, contextSrv.isEditor);
const columns: SilenceTableColumnProps[] = [
{
id: 'state',
@@ -262,7 +251,7 @@ function useColumns(alertManagerSourceName: string) {
});
}
return columns;
- }, [alertManagerSourceName, dispatch, styles, isExternalAM]);
+ }, [alertManagerSourceName, dispatch, styles, permissions]);
}
export default SilencesTable;
diff --git a/public/app/features/alerting/unified/utils/access-control.ts b/public/app/features/alerting/unified/utils/access-control.ts
new file mode 100644
index 00000000000..fa884513679
--- /dev/null
+++ b/public/app/features/alerting/unified/utils/access-control.ts
@@ -0,0 +1,105 @@
+import { AccessControlAction } from 'app/types';
+import { isGrafanaRulesSource } from './datasource';
+import { contextSrv } from 'app/core/services/context_srv';
+
+type RulesSourceType = 'grafana' | 'external';
+
+function getRulesSourceType(alertManagerSourceName: string): RulesSourceType {
+ return isGrafanaRulesSource(alertManagerSourceName) ? 'grafana' : 'external';
+}
+
+export function getInstancesPermissions(rulesSourceName: string) {
+ const sourceType = getRulesSourceType(rulesSourceName);
+
+ const permissions = {
+ read: {
+ grafana: AccessControlAction.AlertingInstanceRead,
+ external: AccessControlAction.AlertingInstancesExternalRead,
+ },
+ create: {
+ grafana: AccessControlAction.AlertingInstanceCreate,
+ external: AccessControlAction.AlertingInstancesExternalWrite,
+ },
+ update: {
+ grafana: AccessControlAction.AlertingInstanceUpdate,
+ external: AccessControlAction.AlertingInstancesExternalWrite,
+ },
+ delete: {
+ grafana: AccessControlAction.AlertingInstanceUpdate,
+ external: AccessControlAction.AlertingInstancesExternalWrite,
+ },
+ };
+
+ return {
+ read: permissions.read[sourceType],
+ create: permissions.create[sourceType],
+ update: permissions.update[sourceType],
+ delete: permissions.delete[sourceType],
+ };
+}
+
+export function getNotificationsPermissions(rulesSourceName: string) {
+ const sourceType = getRulesSourceType(rulesSourceName);
+
+ const permissions = {
+ read: {
+ grafana: AccessControlAction.AlertingNotificationsRead,
+ external: AccessControlAction.AlertingNotificationsExternalRead,
+ },
+ create: {
+ grafana: AccessControlAction.AlertingNotificationsCreate,
+ external: AccessControlAction.AlertingNotificationsExternalWrite,
+ },
+ update: {
+ grafana: AccessControlAction.AlertingNotificationsUpdate,
+ external: AccessControlAction.AlertingNotificationsExternalWrite,
+ },
+ delete: {
+ grafana: AccessControlAction.AlertingNotificationsDelete,
+ external: AccessControlAction.AlertingNotificationsExternalWrite,
+ },
+ };
+
+ return {
+ read: permissions.read[sourceType],
+ create: permissions.create[sourceType],
+ update: permissions.update[sourceType],
+ delete: permissions.delete[sourceType],
+ };
+}
+
+export function getRulesPermissions(rulesSourceName: string) {
+ const sourceType = getRulesSourceType(rulesSourceName);
+
+ const permissions = {
+ read: {
+ grafana: AccessControlAction.AlertingRuleRead,
+ external: AccessControlAction.AlertingRuleExternalRead,
+ },
+ create: {
+ grafana: AccessControlAction.AlertingRuleCreate,
+ external: AccessControlAction.AlertingRuleExternalWrite,
+ },
+ update: {
+ grafana: AccessControlAction.AlertingRuleUpdate,
+ external: AccessControlAction.AlertingRuleExternalWrite,
+ },
+ delete: {
+ grafana: AccessControlAction.AlertingRuleDelete,
+ external: AccessControlAction.AlertingRuleExternalWrite,
+ },
+ };
+
+ return {
+ read: permissions.read[sourceType],
+ create: permissions.create[sourceType],
+ update: permissions.update[sourceType],
+ delete: permissions.delete[sourceType],
+ };
+}
+
+export function evaluateAccess(actions: AccessControlAction[], fallBackUserRoles: string[]) {
+ return () => {
+ return contextSrv.evaluatePermission(() => fallBackUserRoles, actions);
+ };
+}
diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx
index 7939e89f415..9f61c323bf6 100644
--- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx
+++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx
@@ -285,7 +285,13 @@ export class PanelEditorUnconnected extends PureComponent {
aria-label={selectors.components.PanelEditor.DataPane.content}
key="panel-editor-tabs"
>
-
+
,
];
}
diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx
index f38824de47f..af8030b01e9 100644
--- a/public/app/features/explore/ExploreToolbar.tsx
+++ b/public/app/features/explore/ExploreToolbar.tsx
@@ -1,9 +1,9 @@
-import React, { PureComponent, RefObject } from 'react';
+import React, { lazy, PureComponent, RefObject, Suspense } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { ExploreId } from 'app/types/explore';
import { PageToolbar, SetInterval, ToolbarButton, ToolbarButtonRow } from '@grafana/ui';
import { DataSourceInstanceSettings, RawTimeRange } from '@grafana/data';
-import { DataSourcePicker } from '@grafana/runtime';
+import { config, DataSourcePicker } from '@grafana/runtime';
import { StoreState } from 'app/types/store';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import { changeDatasource } from './state/datasource';
@@ -18,7 +18,10 @@ import { LiveTailControls } from './useLiveTailControls';
import { cancelQueries, runQueries } from './state/query';
import { isSplit } from './state/selectors';
import { DashNavButton } from '../dashboard/components/DashNav/DashNavButton';
-import { AddToDashboard } from './AddToDashboard';
+
+const AddToDashboard = lazy(() =>
+ import('./AddToDashboard').then(({ AddToDashboard }) => ({ default: AddToDashboard }))
+);
interface OwnProps {
exploreId: ExploreId;
@@ -116,7 +119,11 @@ class UnConnectedExploreToolbar extends PureComponent {
)}
-
+ {config.featureToggles.explore2Dashboard && (
+
+
+
+ )}
{!isLive && (
;
unmount: () => void;
container: HTMLElement;
} {
diff --git a/public/app/features/panel/state/actions.ts b/public/app/features/panel/state/actions.ts
index 84e12c00199..f3c9d51fa9e 100644
--- a/public/app/features/panel/state/actions.ts
+++ b/public/app/features/panel/state/actions.ts
@@ -2,7 +2,7 @@ import { getPanelPluginNotFound } from 'app/features/panel/components/PanelPlugi
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { loadPanelPlugin } from 'app/features/plugins/admin/state/actions';
import { ThunkResult } from 'app/types';
-import { panelModelAndPluginReady } from './reducers';
+import { changePanelKey, panelModelAndPluginReady } from './reducers';
import { LibraryElementDTO } from 'app/features/library-panels/types';
import { toPanelModelLibraryPanel } from 'app/features/library-panels/utils';
import { PanelOptionsChangedEvent, PanelQueriesChangedEvent } from 'app/types/events';
@@ -114,6 +114,12 @@ export function changeToLibraryPanel(panel: PanelModel, libraryPanel: LibraryEle
panel.generateNewKey();
await dispatch(panelModelAndPluginReady({ key: panel.key, plugin, cleanUpKey: oldKey }));
+ } else {
+ // Even if the plugin is the same, we want to change the key
+ // to force a rerender
+ const oldKey = panel.key;
+ panel.generateNewKey();
+ dispatch(changePanelKey({ oldKey, newKey: panel.key }));
}
panel.configRev = 0;
diff --git a/public/app/features/panel/state/reducers.ts b/public/app/features/panel/state/reducers.ts
index 164f0e46610..4fe2f8dce70 100644
--- a/public/app/features/panel/state/reducers.ts
+++ b/public/app/features/panel/state/reducers.ts
@@ -26,6 +26,10 @@ const panelsSlice = createSlice({
plugin: action.payload.plugin,
};
},
+ changePanelKey: (state, action: PayloadAction<{ oldKey: string; newKey: string }>) => {
+ state[action.payload.newKey] = state[action.payload.oldKey];
+ delete state[action.payload.oldKey];
+ },
cleanUpPanelState: (state, action: PayloadAction<{ key: string }>) => {
cleanUpAngularComponent(state[action.payload.key]);
delete state[action.payload.key];
@@ -64,8 +68,13 @@ export interface SetPanelInstanceStatePayload {
value: any;
}
-export const { panelModelAndPluginReady, setPanelAngularComponent, setPanelInstanceState, cleanUpPanelState } =
- panelsSlice.actions;
+export const {
+ panelModelAndPluginReady,
+ setPanelAngularComponent,
+ setPanelInstanceState,
+ cleanUpPanelState,
+ changePanelKey,
+} = panelsSlice.actions;
export const panelsReducer = panelsSlice.reducer;
diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts
index ebe372a6fff..3128e26f815 100644
--- a/public/app/features/plugins/built_in_plugins.ts
+++ b/public/app/features/plugins/built_in_plugins.ts
@@ -47,7 +47,6 @@ import * as statusHistoryPanel from 'app/plugins/panel/status-history/module';
import * as candlestickPanel from 'app/plugins/panel/candlestick/module';
import * as xyChartPanel from 'app/plugins/panel/xychart/module';
import * as dashListPanel from 'app/plugins/panel/dashlist/module';
-import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module';
import * as alertListPanel from 'app/plugins/panel/alertlist/module';
import * as annoListPanel from 'app/plugins/panel/annolist/module';
import * as heatmapPanelNG from 'app/plugins/panel/heatmap-new/module';
@@ -110,7 +109,6 @@ const builtInPlugins: any = {
'app/plugins/panel/canvas/module': canvasPanel,
'app/plugins/panel/icon/module': iconPanel,
'app/plugins/panel/dashlist/module': dashListPanel,
- 'app/plugins/panel/pluginlist/module': pluginsListPanel,
'app/plugins/panel/alertlist/module': alertListPanel,
'app/plugins/panel/annolist/module': annoListPanel,
'app/plugins/panel/heatmap/module': heatmapPanel,
diff --git a/public/app/features/search/page/table/Table.tsx b/public/app/features/search/page/table/Table.tsx
index b91e96f67d2..e28e17fa222 100644
--- a/public/app/features/search/page/table/Table.tsx
+++ b/public/app/features/search/page/table/Table.tsx
@@ -1,11 +1,11 @@
import React, { useMemo } from 'react';
-import { useTable, useBlockLayout, Column, TableOptions, Cell } from 'react-table';
+import { useTable, Column, TableOptions, Cell, useAbsoluteLayout } from 'react-table';
import { DataFrame, DataFrameType, DataFrameView, DataSourceRef, Field, GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
-import { useStyles2 } from '@grafana/ui';
import { FixedSizeList } from 'react-window';
import { TableCell } from '@grafana/ui/src/components/Table/TableCell';
import { getTableStyles } from '@grafana/ui/src/components/Table/styles';
+import { useStyles2 } from '@grafana/ui';
import { LocationInfo } from '../../service';
import { generateColumns } from './columns';
@@ -66,7 +66,7 @@ export const Table = ({ data, width, tags, onTagFilterChange, onDatasourceChange
[memoizedColumns, memoizedData]
);
- const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable(options, useBlockLayout);
+ const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable(options, useAbsoluteLayout);
const RenderRow = React.useCallback(
({ index: rowIndex, style }) => {
@@ -78,35 +78,15 @@ export const Table = ({ data, width, tags, onTagFilterChange, onDatasourceChange
return (
{row.cells.map((cell: Cell, index: number) => {
- if (
- cell.column.id === 'column-checkbox' ||
- cell.column.id === 'column-tags' ||
- cell.column.id === 'column-datasource'
- ) {
- return (
-
- );
- }
-
return (
-
-
+
+
);
})}
@@ -178,7 +158,12 @@ const getStyles = (theme: GrafanaTheme2) => {
align-items: center;
`,
cellWrapper: css`
- display: flex;
+ div {
+ border-right: none;
+ &:hover {
+ box-shadow: none;
+ }
+ }
`,
headerCell: css`
padding-top: 2px;
@@ -190,17 +175,30 @@ const getStyles = (theme: GrafanaTheme2) => {
align-items: center;
`,
rowContainer: css`
+ label: row;
&:hover {
background-color: ${rowHoverBg};
}
`,
typeIcon: css`
+ margin-left: 5px;
margin-right: 9.5px;
vertical-align: middle;
display: inline-block;
margin-bottom: ${theme.v1.spacing.xxs};
fill: ${theme.colors.text.secondary};
`,
+ datasourceItem: css`
+ span {
+ &:hover {
+ color: ${theme.colors.text.link};
+ }
+ }
+ `,
+ invalidDatasourceItem: css`
+ color: ${theme.colors.error.main};
+ text-decoration: line-through;
+ `,
typeText: css`
color: ${theme.colors.text.secondary};
`,
@@ -218,13 +216,14 @@ const getStyles = (theme: GrafanaTheme2) => {
margin-top: 5px;
`,
infoWrap: css`
+ color: ${theme.colors.text.secondary};
span {
margin-right: 10px;
}
`,
tagList: css`
justify-content: flex-start;
- pointer-events: auto;
+ flex-wrap: nowrap;
`,
};
};
diff --git a/public/app/features/search/page/table/columns.tsx b/public/app/features/search/page/table/columns.tsx
index a88634fdf91..5edab606248 100644
--- a/public/app/features/search/page/table/columns.tsx
+++ b/public/app/features/search/page/table/columns.tsx
@@ -65,6 +65,7 @@ export const generateColumns = (
const DATASOURCE_COLUMN_WIDTH = 200;
const INFO_COLUMN_WIDTH = 100;
const LOCATION_COLUMN_WIDTH = 200;
+ const TAGS_COLUMN_WIDTH = 200;
width = TYPE_COLUMN_WIDTH;
if (isDashboardList) {
@@ -92,12 +93,28 @@ export const generateColumns = (
// Show datasources if we have any
if (access.datasource && hasFieldValue(access.datasource)) {
width = DATASOURCE_COLUMN_WIDTH;
- columns.push(makeDataSourceColumn(access.datasource, width, styles.typeIcon, onDatasourceChange));
+ columns.push(
+ makeDataSourceColumn(
+ access.datasource,
+ width,
+ styles.typeIcon,
+ styles.datasourceItem,
+ styles.invalidDatasourceItem,
+ onDatasourceChange
+ )
+ );
+ availableWidth -= width;
+ }
+
+ // Show tags if we have any
+ if (access.tags && hasFieldValue(access.tags)) {
+ width = TAGS_COLUMN_WIDTH;
+ columns.push(makeTagsColumn(access.tags, width, styles.tagList, tags, onTagFilterChange));
availableWidth -= width;
}
if (isDashboardList) {
- width = INFO_COLUMN_WIDTH;
+ width = Math.max(availableWidth, INFO_COLUMN_WIDTH);
columns.push({
Cell: DefaultCell,
id: `column-info`,
@@ -109,8 +126,8 @@ export const generateColumns = (
},
width: width,
});
- availableWidth -= width;
} else {
+ width = Math.max(availableWidth, LOCATION_COLUMN_WIDTH);
columns.push({
Cell: DefaultCell,
id: `column-location`,
@@ -138,15 +155,8 @@ export const generateColumns = (
}
return null;
},
- width: LOCATION_COLUMN_WIDTH,
+ width: width,
});
- availableWidth -= width;
- }
-
- // Show tags if we have any
- if (access.tags && hasFieldValue(access.tags)) {
- width = Math.max(availableWidth, 250);
- columns.push(makeTagsColumn(access.tags, width, styles.tagList, tags, onTagFilterChange));
}
return columns;
@@ -176,6 +186,8 @@ function makeDataSourceColumn(
field: Field
,
width: number,
iconClass: string,
+ datasourceItemClass: string,
+ invalidDatasourceItemClass: string,
onDatasourceChange: (datasource?: string) => void
): TableColumn {
return {
@@ -188,27 +200,30 @@ function makeDataSourceColumn(
if (dslist?.length) {
const srv = getDataSourceSrv();
return (
-
+
);
diff --git a/public/app/features/serviceaccounts/CreateTokenModal.tsx b/public/app/features/serviceaccounts/CreateServiceAccountTokenModal.tsx
similarity index 96%
rename from public/app/features/serviceaccounts/CreateTokenModal.tsx
rename to public/app/features/serviceaccounts/CreateServiceAccountTokenModal.tsx
index 4cf8338a0d1..996ee89962e 100644
--- a/public/app/features/serviceaccounts/CreateTokenModal.tsx
+++ b/public/app/features/serviceaccounts/CreateServiceAccountTokenModal.tsx
@@ -64,8 +64,11 @@ export const CreateTokenModal = ({ isOpen, token, onCreateToken, onClose }: Crea
{
diff --git a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx
index 2c94e38b171..408d4346d25 100644
--- a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx
+++ b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx
@@ -66,10 +66,18 @@ export function ServiceAccountProfile({
return (
<>
- Information
-
-
-
+
+
+
+
+
+ {serviceAccount.name}
+
+
+ Information
diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx
index 3d597ed8ed2..2970193f335 100644
--- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx
+++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx
@@ -78,6 +78,7 @@ const ServiceAccountsListPage = ({
return (
+ Service accounts
- Display name
+ Account
ID
Roles
Status
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/datasource.ts
index 23492d50d3f..1793aa8a397 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/datasource.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/datasource.ts
@@ -16,6 +16,14 @@ export default function createMockDatasource(overrides?: DeepPartial
},
getSubscriptions: jest.fn().mockResolvedValueOnce([]),
defaultSubscriptionId: 'subscriptionId',
+ newGetMetricNamespaces: jest.fn().mockResolvedValueOnce([]),
+ newGetMetricNames: jest.fn().mockResolvedValueOnce([]),
+ newGetMetricMetadata: jest.fn().mockResolvedValueOnce({
+ primaryAggType: 'Average',
+ supportedAggTypes: ['Average', 'Maximum', 'Minimum'],
+ supportedTimeGrains: [],
+ dimensions: [],
+ }),
},
getAzureLogAnalyticsWorkspaces: jest.fn().mockResolvedValueOnce([]),
@@ -41,6 +49,7 @@ export default function createMockDatasource(overrides?: DeepPartial
getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue([]),
getResourcesForResourceGroup: jest.fn().mockResolvedValue([]),
getResourceURIFromWorkspace: jest.fn().mockReturnValue(''),
+ getResourceURIDisplayProperties: jest.fn().mockResolvedValue({}),
},
...overrides,
};
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query.ts
index 883c4452e58..8ed51200cea 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query.ts
@@ -17,6 +17,8 @@ export default function createMockQuery(): AzureMonitorQuery {
azureMonitor: {
// aggOptions: [],
+ resourceUri:
+ '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
aggregation: 'Average',
allowedTimeGrainsMs: [60000, 300000, 900000, 1800000, 3600000, 21600000, 43200000, 86400000],
// dimensionFilter: '*',
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/resourcePickerData.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/resourcePickerData.ts
index 9d963262bd7..e4659041b64 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/resourcePickerData.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/resourcePickerData.ts
@@ -10,6 +10,7 @@ export default function createMockResourcePickerData(overrides?: DeepPartial [
{
id: 'dev-1',
uri: '/subscriptions/def-456/resourceGroups/dev-1',
- name: 'Development',
+ name: 'Development 1',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
@@ -39,7 +39,7 @@ export const createMockResourceGroupsBySubscription = (): ResourceRowGroup => [
{
id: 'dev-2',
uri: '/subscriptions/def-456/resourceGroups/dev-2',
- name: 'Development',
+ name: 'Development 2',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
@@ -55,7 +55,7 @@ export const createMockResourceGroupsBySubscription = (): ResourceRowGroup => [
{
id: 'dev-4',
uri: '/subscriptions/def-456/resourceGroups/dev-4',
- name: 'Development',
+ name: 'Development 3',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
@@ -63,7 +63,7 @@ export const createMockResourceGroupsBySubscription = (): ResourceRowGroup => [
{
id: 'dev-5',
uri: '/subscriptions/def-456/resourceGroups/dev-5',
- name: 'Development',
+ name: 'Development 4',
type: ResourceRowType.ResourceGroup,
typeLabel: 'Resource Group',
children: [],
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts
index 8236bb81437..a6cbb496067 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts
@@ -75,266 +75,59 @@ describe('AzureMonitorDatasource', () => {
});
});
});
- describe('When performing getSubscriptions', () => {
+
+ describe('When performing newGetMetricNamespaces', () => {
const response = {
value: [
{
- id: '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572',
- subscriptionId: '99999999-cccc-bbbb-aaaa-9106972f9572',
- tenantId: '99999999-aaaa-bbbb-cccc-51c4f982ec48',
- displayName: 'Primary Subscription',
- state: 'Enabled',
- subscriptionPolicies: {
- locationPlacementId: 'Public_2014-09-01',
- quotaId: 'PayAsYouGo_2014-09-01',
- spendingLimit: 'Off',
+ id: '/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1/providers/microsoft.insights/metricNamespaces/Azure.ApplicationInsights',
+ name: 'Azure.ApplicationInsights',
+ type: 'Microsoft.Insights/metricNamespaces',
+ classification: 'Custom',
+ properties: {
+ metricNamespaceName: 'Azure.ApplicationInsights',
},
- authorizationSource: 'RoleBased',
- },
- ],
- count: {
- type: 'Total',
- value: 1,
- },
- };
-
- beforeEach(() => {
- ctx.instanceSettings.jsonData.azureAuthType = 'msi';
- ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response);
- });
-
- it('should return list of subscriptions', () => {
- return ctx.ds.getSubscriptions().then((results: Array<{ text: string; value: string }>) => {
- expect(results.length).toEqual(1);
- expect(results[0].text).toEqual('Primary Subscription');
- expect(results[0].value).toEqual('99999999-cccc-bbbb-aaaa-9106972f9572');
- });
- });
- });
-
- describe('When performing getResourceGroups', () => {
- const response = {
- value: [{ name: 'grp1' }, { name: 'grp2' }],
- };
-
- beforeEach(() => {
- ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response);
- });
-
- it('should return list of Resource Groups', () => {
- return ctx.ds.getResourceGroups('subscriptionId').then((results: Array<{ text: string; value: string }>) => {
- expect(results.length).toEqual(2);
- expect(results[0].text).toEqual('grp1');
- expect(results[0].value).toEqual('grp1');
- expect(results[1].text).toEqual('grp2');
- expect(results[1].value).toEqual('grp2');
- });
- });
- });
-
- describe('When performing getMetricDefinitions', () => {
- const response = {
- value: [
- {
- name: 'test',
- type: 'Microsoft.Network/networkInterfaces',
},
{
- location: 'northeurope',
- name: 'northeur',
- type: 'Microsoft.Compute/virtualMachines',
- },
- {
- location: 'westcentralus',
- name: 'us',
- type: 'Microsoft.Compute/virtualMachines',
- },
- {
- name: 'IHaveNoMetrics',
- type: 'IShouldBeFilteredOut',
- },
- {
- name: 'storageTest',
- type: 'Microsoft.Storage/storageAccounts',
+ id: '/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1/providers/microsoft.insights/metricNamespaces/microsoft.insights-components',
+ name: 'microsoft.insights-components',
+ type: 'Microsoft.Insights/metricNamespaces',
+ classification: 'Platform',
+ properties: {
+ metricNamespaceName: 'microsoft.insights/components',
+ },
},
],
};
beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
- const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups';
- expect(path).toBe(basePath + '/nodesapp/resources?api-version=2021-04-01');
+ const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp';
+ const expected =
+ basePath +
+ '/providers/microsoft.insights/components/resource1' +
+ '/providers/microsoft.insights/metricNamespaces?api-version=2017-12-01-preview';
+ expect(path).toBe(expected);
return Promise.resolve(response);
});
});
- it('should return list of Metric Definitions with no duplicates and no unsupported namespaces', () => {
- return ctx.ds
- .getMetricDefinitions('9935389e-9122-4ef9-95f9-1513dd24753f', 'nodesapp')
+ it('should return list of Metric Namspaces', () => {
+ return ctx.ds.azureMonitorDatasource
+ .newGetMetricNamespaces(
+ '/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1'
+ )
.then((results: Array<{ text: string; value: string }>) => {
- expect(results.length).toEqual(7);
- expect(results[0].text).toEqual('Network interface');
- expect(results[0].value).toEqual('Microsoft.Network/networkInterfaces');
- expect(results[1].text).toEqual('Virtual machine');
- expect(results[1].value).toEqual('Microsoft.Compute/virtualMachines');
- expect(results[2].text).toEqual('Storage account');
- expect(results[2].value).toEqual('Microsoft.Storage/storageAccounts');
- expect(results[3].text).toEqual('Microsoft.Storage/storageAccounts/blobServices');
- expect(results[3].value).toEqual('Microsoft.Storage/storageAccounts/blobServices');
- expect(results[4].text).toEqual('Microsoft.Storage/storageAccounts/fileServices');
- expect(results[4].value).toEqual('Microsoft.Storage/storageAccounts/fileServices');
- expect(results[5].text).toEqual('Microsoft.Storage/storageAccounts/tableServices');
- expect(results[5].value).toEqual('Microsoft.Storage/storageAccounts/tableServices');
- expect(results[6].text).toEqual('Microsoft.Storage/storageAccounts/queueServices');
- expect(results[6].value).toEqual('Microsoft.Storage/storageAccounts/queueServices');
+ expect(results.length).toEqual(2);
+ expect(results[0].text).toEqual('Azure.ApplicationInsights');
+ expect(results[0].value).toEqual('Azure.ApplicationInsights');
+ expect(results[1].text).toEqual('microsoft.insights-components');
+ expect(results[1].value).toEqual('microsoft.insights/components');
});
});
});
- describe('When performing getResourceNames', () => {
- let subscription = '9935389e-9122-4ef9-95f9-1513dd24753f';
- let resourceGroup = 'nodeapp';
- let metricDefinition = 'microsoft.insights/components';
-
- beforeEach(() => {
- subscription = '9935389e-9122-4ef9-95f9-1513dd24753f';
- resourceGroup = 'nodeapp';
- metricDefinition = 'microsoft.insights/components';
- });
-
- describe('and there are no special cases', () => {
- const response = {
- value: [
- {
- name: 'Failure Anomalies - nodeapp',
- type: 'microsoft.insights/alertrules',
- },
- {
- name: resourceGroup,
- type: metricDefinition,
- },
- ],
- };
-
- beforeEach(() => {
- ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
- const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
- expect(path).toBe(
- `${basePath}/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`
- );
- return Promise.resolve(response);
- });
- });
-
- it('should return list of Resource Names', () => {
- return ctx.ds
- .getResourceNames(subscription, resourceGroup, metricDefinition)
- .then((results: Array<{ text: string; value: string }>) => {
- expect(results.length).toEqual(1);
- expect(results[0].text).toEqual('nodeapp');
- expect(results[0].value).toEqual('nodeapp');
- });
- });
-
- it('should return ignore letter case', () => {
- metricDefinition = 'microsoft.insights/Components';
- return ctx.ds
- .getResourceNames(subscription, resourceGroup, metricDefinition)
- .then((results: Array<{ text: string; value: string }>) => {
- expect(results.length).toEqual(1);
- expect(results[0].text).toEqual('nodeapp');
- expect(results[0].value).toEqual('nodeapp');
- });
- });
- });
-
- describe('and the metric definition is blobServices', () => {
- const response = {
- value: [
- {
- name: 'Failure Anomalies - nodeapp',
- type: 'microsoft.insights/alertrules',
- },
- {
- name: 'storagetest',
- type: 'Microsoft.Storage/storageAccounts',
- },
- ],
- };
-
- beforeEach(() => {
- ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
- const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
- expect(path).toBe(
- basePath +
- `/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`
- );
- return Promise.resolve(response);
- });
- });
-
- it('should return list of Resource Names', () => {
- metricDefinition = 'Microsoft.Storage/storageAccounts/blobServices';
- return ctx.ds
- .getResourceNames(subscription, resourceGroup, metricDefinition)
- .then((results: Array<{ text: string; value: string }>) => {
- expect(results.length).toEqual(1);
- expect(results[0].text).toEqual('storagetest/default');
- expect(results[0].value).toEqual('storagetest/default');
- });
- });
- });
-
- describe('and there are several pages', () => {
- const skipToken = 'token';
- const response1 = {
- value: [
- {
- name: `${resourceGroup}1`,
- type: metricDefinition,
- },
- ],
- nextLink: `https://management.azure.com/resourceuri?$skiptoken=${skipToken}`,
- };
- const response2 = {
- value: [
- {
- name: `${resourceGroup}2`,
- type: metricDefinition,
- },
- ],
- };
-
- beforeEach(() => {
- const fn = jest.fn();
- ctx.ds.azureMonitorDatasource.getResource = fn;
- const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
- const expectedPath = `${basePath}/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`;
- // first page
- fn.mockImplementationOnce((path: string) => {
- expect(path).toBe(expectedPath);
- return Promise.resolve(response1);
- });
- // second page
- fn.mockImplementationOnce((path: string) => {
- expect(path).toBe(`${expectedPath}&$skiptoken=${skipToken}`);
- return Promise.resolve(response2);
- });
- });
-
- it('should return list of Resource Names', () => {
- return ctx.ds
- .getResourceNames(subscription, resourceGroup, metricDefinition)
- .then((results: Array<{ text: string; value: string }>) => {
- expect(results.length).toEqual(2);
- expect(results[0].value).toEqual(`${resourceGroup}1`);
- expect(results[1].value).toEqual(`${resourceGroup}2`);
- });
- });
- });
- });
-
- describe('When performing getMetricNames', () => {
+ describe('When performing newGetMetricNames', () => {
const response = {
value: [
{
@@ -383,12 +176,9 @@ describe('AzureMonitorDatasource', () => {
});
it('should return list of Metric Definitions', () => {
- return ctx.ds
- .getMetricNames(
- '9935389e-9122-4ef9-95f9-1513dd24753f',
- 'nodeapp',
- 'microsoft.insights/components',
- 'resource1',
+ return ctx.ds.azureMonitorDatasource
+ .newGetMetricNames(
+ '/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1',
'default'
)
.then((results: Array<{ text: string; value: string }>) => {
@@ -401,7 +191,7 @@ describe('AzureMonitorDatasource', () => {
});
});
- describe('When performing getMetricMetadata', () => {
+ describe('When performing newGetMetricMetadata', () => {
const response = {
value: [
{
@@ -450,12 +240,9 @@ describe('AzureMonitorDatasource', () => {
});
it('should return Aggregation metadata for a Metric', () => {
- return ctx.ds
- .getMetricMetadata(
- '9935389e-9122-4ef9-95f9-1513dd24753f',
- 'nodeapp',
- 'microsoft.insights/components',
- 'resource1',
+ return ctx.ds.azureMonitorDatasource
+ .newGetMetricMetadata(
+ '/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1',
'default',
'UsedCapacity'
)
@@ -467,130 +254,524 @@ describe('AzureMonitorDatasource', () => {
});
});
- describe('When performing getMetricMetadata on metrics with dimensions', () => {
- const response = {
- value: [
- {
- name: {
- value: 'Transactions',
- localizedValue: 'Transactions',
+ describe('Legacy Azure Monitor Query Object data fetchers', () => {
+ describe('When performing getSubscriptions', () => {
+ const response = {
+ value: [
+ {
+ id: '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572',
+ subscriptionId: '99999999-cccc-bbbb-aaaa-9106972f9572',
+ tenantId: '99999999-aaaa-bbbb-cccc-51c4f982ec48',
+ displayName: 'Primary Subscription',
+ state: 'Enabled',
+ subscriptionPolicies: {
+ locationPlacementId: 'Public_2014-09-01',
+ quotaId: 'PayAsYouGo_2014-09-01',
+ spendingLimit: 'Off',
+ },
+ authorizationSource: 'RoleBased',
},
- unit: 'Count',
- primaryAggregationType: 'Total',
- supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'],
- isDimensionRequired: false,
- dimensions: [
+ ],
+ count: {
+ type: 'Total',
+ value: 1,
+ },
+ };
+
+ beforeEach(() => {
+ ctx.instanceSettings.jsonData.azureAuthType = 'msi';
+ ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response);
+ });
+
+ it('should return list of subscriptions', () => {
+ return ctx.ds.getSubscriptions().then((results: Array<{ text: string; value: string }>) => {
+ expect(results.length).toEqual(1);
+ expect(results[0].text).toEqual('Primary Subscription');
+ expect(results[0].value).toEqual('99999999-cccc-bbbb-aaaa-9106972f9572');
+ });
+ });
+ });
+
+ describe('When performing getResourceGroups', () => {
+ const response = {
+ value: [{ name: 'grp1' }, { name: 'grp2' }],
+ };
+
+ beforeEach(() => {
+ ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response);
+ });
+
+ it('should return list of Resource Groups', () => {
+ return ctx.ds.getResourceGroups('subscriptionId').then((results: Array<{ text: string; value: string }>) => {
+ expect(results.length).toEqual(2);
+ expect(results[0].text).toEqual('grp1');
+ expect(results[0].value).toEqual('grp1');
+ expect(results[1].text).toEqual('grp2');
+ expect(results[1].value).toEqual('grp2');
+ });
+ });
+ });
+
+ describe('When performing getMetricDefinitions', () => {
+ const response = {
+ value: [
+ {
+ name: 'test',
+ type: 'Microsoft.Network/networkInterfaces',
+ },
+ {
+ location: 'northeurope',
+ name: 'northeur',
+ type: 'Microsoft.Compute/virtualMachines',
+ },
+ {
+ location: 'westcentralus',
+ name: 'us',
+ type: 'Microsoft.Compute/virtualMachines',
+ },
+ {
+ name: 'IHaveNoMetrics',
+ type: 'IShouldBeFilteredOut',
+ },
+ {
+ name: 'storageTest',
+ type: 'Microsoft.Storage/storageAccounts',
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
+ const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups';
+ expect(path).toBe(basePath + '/nodesapp/resources?api-version=2021-04-01');
+ return Promise.resolve(response);
+ });
+ });
+
+ it('should return list of Metric Definitions with no duplicates and no unsupported namespaces', () => {
+ return ctx.ds
+ .getMetricDefinitions('9935389e-9122-4ef9-95f9-1513dd24753f', 'nodesapp')
+ .then((results: Array<{ text: string; value: string }>) => {
+ expect(results.length).toEqual(7);
+ expect(results[0].text).toEqual('Network interface');
+ expect(results[0].value).toEqual('Microsoft.Network/networkInterfaces');
+ expect(results[1].text).toEqual('Virtual machine');
+ expect(results[1].value).toEqual('Microsoft.Compute/virtualMachines');
+ expect(results[2].text).toEqual('Storage account');
+ expect(results[2].value).toEqual('Microsoft.Storage/storageAccounts');
+ expect(results[3].text).toEqual('Microsoft.Storage/storageAccounts/blobServices');
+ expect(results[3].value).toEqual('Microsoft.Storage/storageAccounts/blobServices');
+ expect(results[4].text).toEqual('Microsoft.Storage/storageAccounts/fileServices');
+ expect(results[4].value).toEqual('Microsoft.Storage/storageAccounts/fileServices');
+ expect(results[5].text).toEqual('Microsoft.Storage/storageAccounts/tableServices');
+ expect(results[5].value).toEqual('Microsoft.Storage/storageAccounts/tableServices');
+ expect(results[6].text).toEqual('Microsoft.Storage/storageAccounts/queueServices');
+ expect(results[6].value).toEqual('Microsoft.Storage/storageAccounts/queueServices');
+ });
+ });
+ });
+
+ describe('When performing getResourceNames', () => {
+ let subscription = '9935389e-9122-4ef9-95f9-1513dd24753f';
+ let resourceGroup = 'nodeapp';
+ let metricDefinition = 'microsoft.insights/components';
+
+ beforeEach(() => {
+ subscription = '9935389e-9122-4ef9-95f9-1513dd24753f';
+ resourceGroup = 'nodeapp';
+ metricDefinition = 'microsoft.insights/components';
+ });
+
+ describe('and there are no special cases', () => {
+ const response = {
+ value: [
{
- value: 'ResponseType',
- localizedValue: 'Response type',
+ name: 'Failure Anomalies - nodeapp',
+ type: 'microsoft.insights/alertrules',
},
{
- value: 'GeoType',
- localizedValue: 'Geo type',
- },
- {
- value: 'ApiName',
- localizedValue: 'API name',
+ name: resourceGroup,
+ type: metricDefinition,
},
],
- },
- {
- name: {
- value: 'FreeCapacity',
- localizedValue: 'Free capacity',
+ };
+
+ beforeEach(() => {
+ ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
+ const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
+ expect(path).toBe(
+ `${basePath}/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`
+ );
+ return Promise.resolve(response);
+ });
+ });
+
+ it('should return list of Resource Names', () => {
+ return ctx.ds
+ .getResourceNames(subscription, resourceGroup, metricDefinition)
+ .then((results: Array<{ text: string; value: string }>) => {
+ expect(results.length).toEqual(1);
+ expect(results[0].text).toEqual('nodeapp');
+ expect(results[0].value).toEqual('nodeapp');
+ });
+ });
+
+ it('should return ignore letter case', () => {
+ metricDefinition = 'microsoft.insights/Components';
+ return ctx.ds
+ .getResourceNames(subscription, resourceGroup, metricDefinition)
+ .then((results: Array<{ text: string; value: string }>) => {
+ expect(results.length).toEqual(1);
+ expect(results[0].text).toEqual('nodeapp');
+ expect(results[0].value).toEqual('nodeapp');
+ });
+ });
+ });
+
+ describe('and the metric definition is blobServices', () => {
+ const response = {
+ value: [
+ {
+ name: 'Failure Anomalies - nodeapp',
+ type: 'microsoft.insights/alertrules',
+ },
+ {
+ name: 'storagetest',
+ type: 'Microsoft.Storage/storageAccounts',
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
+ const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
+ expect(path).toBe(
+ basePath +
+ `/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`
+ );
+ return Promise.resolve(response);
+ });
+ });
+
+ it('should return list of Resource Names', () => {
+ metricDefinition = 'Microsoft.Storage/storageAccounts/blobServices';
+ return ctx.ds
+ .getResourceNames(subscription, resourceGroup, metricDefinition)
+ .then((results: Array<{ text: string; value: string }>) => {
+ expect(results.length).toEqual(1);
+ expect(results[0].text).toEqual('storagetest/default');
+ expect(results[0].value).toEqual('storagetest/default');
+ });
+ });
+ });
+
+ describe('and there are several pages', () => {
+ const skipToken = 'token';
+ const response1 = {
+ value: [
+ {
+ name: `${resourceGroup}1`,
+ type: metricDefinition,
+ },
+ ],
+ nextLink: `https://management.azure.com/resourceuri?$skiptoken=${skipToken}`,
+ };
+ const response2 = {
+ value: [
+ {
+ name: `${resourceGroup}2`,
+ type: metricDefinition,
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ const fn = jest.fn();
+ ctx.ds.azureMonitorDatasource.getResource = fn;
+ const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
+ const expectedPath = `${basePath}/${resourceGroup}/resources?$filter=resourceType eq '${metricDefinition}'&api-version=2021-04-01`;
+ // first page
+ fn.mockImplementationOnce((path: string) => {
+ expect(path).toBe(expectedPath);
+ return Promise.resolve(response1);
+ });
+ // second page
+ fn.mockImplementationOnce((path: string) => {
+ expect(path).toBe(`${expectedPath}&$skiptoken=${skipToken}`);
+ return Promise.resolve(response2);
+ });
+ });
+
+ it('should return list of Resource Names', () => {
+ return ctx.ds
+ .getResourceNames(subscription, resourceGroup, metricDefinition)
+ .then((results: Array<{ text: string; value: string }>) => {
+ expect(results.length).toEqual(2);
+ expect(results[0].value).toEqual(`${resourceGroup}1`);
+ expect(results[1].value).toEqual(`${resourceGroup}2`);
+ });
+ });
+ });
+ });
+
+ describe('When performing getMetricNames', () => {
+ const response = {
+ value: [
+ {
+ name: {
+ value: 'UsedCapacity',
+ localizedValue: 'Used capacity',
+ },
+ unit: 'CountPerSecond',
+ primaryAggregationType: 'Total',
+ supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'],
+ metricAvailabilities: [
+ { timeGrain: 'PT1H', retention: 'P93D' },
+ { timeGrain: 'PT6H', retention: 'P93D' },
+ { timeGrain: 'PT12H', retention: 'P93D' },
+ { timeGrain: 'P1D', retention: 'P93D' },
+ ],
},
- unit: 'CountPerSecond',
- primaryAggregationType: 'Average',
- supportedAggregationTypes: ['None', 'Average'],
- },
- ],
- };
+ {
+ name: {
+ value: 'FreeCapacity',
+ localizedValue: 'Free capacity',
+ },
+ unit: 'CountPerSecond',
+ primaryAggregationType: 'Average',
+ supportedAggregationTypes: ['None', 'Average'],
+ metricAvailabilities: [
+ { timeGrain: 'PT1H', retention: 'P93D' },
+ { timeGrain: 'PT6H', retention: 'P93D' },
+ { timeGrain: 'PT12H', retention: 'P93D' },
+ { timeGrain: 'P1D', retention: 'P93D' },
+ ],
+ },
+ ],
+ };
- beforeEach(() => {
- ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
- const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp';
- const expected =
- basePath +
- '/providers/microsoft.insights/components/resource1' +
- '/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default';
- expect(path).toBe(expected);
- return Promise.resolve(response);
- });
- });
-
- it('should return dimensions for a Metric that has dimensions', () => {
- return ctx.ds
- .getMetricMetadata(
- '9935389e-9122-4ef9-95f9-1513dd24753f',
- 'nodeapp',
- 'microsoft.insights/components',
- 'resource1',
- 'default',
- 'Transactions'
- )
- .then((results: any) => {
- expect(results.dimensions).toMatchInlineSnapshot(`
- Array [
- Object {
- "label": "Response type",
- "value": "ResponseType",
- },
- Object {
- "label": "Geo type",
- "value": "GeoType",
- },
- Object {
- "label": "API name",
- "value": "ApiName",
- },
- ]
- `);
+ beforeEach(() => {
+ ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
+ const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp';
+ const expected =
+ basePath +
+ '/providers/microsoft.insights/components/resource1' +
+ '/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default';
+ expect(path).toBe(expected);
+ return Promise.resolve(response);
});
- });
-
- describe('When performing targetContainsTemplate', () => {
- it('should return false when no variable is being used', () => {
- const query = createMockQuery();
- query.queryType = AzureQueryType.AzureMonitor;
- expect(ctx.ds.targetContainsTemplate(query)).toEqual(false);
});
- it('should return true when subscriptions field is using a variable', () => {
- const query = createMockQuery();
- const templateSrv = new TemplateSrv();
- templateSrv.init([subscriptionsVariable]);
-
- const ds = new AzureMonitorDatasource(ctx.instanceSettings, templateSrv);
- query.queryType = AzureQueryType.AzureMonitor;
- query.subscription = `$${subscriptionsVariable.name}`;
- expect(ds.targetContainsTemplate(query)).toEqual(true);
- });
-
- it('should return false when a variable is used in a different part of the query', () => {
- const query = createMockQuery();
- const templateSrv = new TemplateSrv();
- templateSrv.init([singleVariable]);
-
- const ds = new AzureMonitorDatasource(ctx.instanceSettings, templateSrv);
- query.queryType = AzureQueryType.AzureMonitor;
- query.azureLogAnalytics = { resource: `$${singleVariable.name}` };
- expect(ds.targetContainsTemplate(query)).toEqual(false);
+ it('should return list of Metric Definitions', () => {
+ return ctx.ds
+ .getMetricNames(
+ '9935389e-9122-4ef9-95f9-1513dd24753f',
+ 'nodeapp',
+ 'microsoft.insights/components',
+ 'resource1',
+ 'default'
+ )
+ .then((results: Array<{ text: string; value: string }>) => {
+ expect(results.length).toEqual(2);
+ expect(results[0].text).toEqual('Used capacity');
+ expect(results[0].value).toEqual('UsedCapacity');
+ expect(results[1].text).toEqual('Free capacity');
+ expect(results[1].value).toEqual('FreeCapacity');
+ });
});
});
- it('should return an empty array for a Metric that does not have dimensions', () => {
- return ctx.ds
- .getMetricMetadata(
- '9935389e-9122-4ef9-95f9-1513dd24753f',
- 'nodeapp',
- 'microsoft.insights/components',
- 'resource1',
- 'default',
- 'FreeCapacity'
- )
- .then((results: any) => {
- expect(results.dimensions.length).toEqual(0);
+ describe('When performing getMetricMetadata', () => {
+ const response = {
+ value: [
+ {
+ name: {
+ value: 'UsedCapacity',
+ localizedValue: 'Used capacity',
+ },
+ unit: 'CountPerSecond',
+ primaryAggregationType: 'Total',
+ supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'],
+ metricAvailabilities: [
+ { timeGrain: 'PT1H', retention: 'P93D' },
+ { timeGrain: 'PT6H', retention: 'P93D' },
+ { timeGrain: 'PT12H', retention: 'P93D' },
+ { timeGrain: 'P1D', retention: 'P93D' },
+ ],
+ },
+ {
+ name: {
+ value: 'FreeCapacity',
+ localizedValue: 'Free capacity',
+ },
+ unit: 'CountPerSecond',
+ primaryAggregationType: 'Average',
+ supportedAggregationTypes: ['None', 'Average'],
+ metricAvailabilities: [
+ { timeGrain: 'PT1H', retention: 'P93D' },
+ { timeGrain: 'PT6H', retention: 'P93D' },
+ { timeGrain: 'PT12H', retention: 'P93D' },
+ { timeGrain: 'P1D', retention: 'P93D' },
+ ],
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
+ const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp';
+ const expected =
+ basePath +
+ '/providers/microsoft.insights/components/resource1' +
+ '/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default';
+ expect(path).toBe(expected);
+ return Promise.resolve(response);
});
+ });
+
+ it('should return Aggregation metadata for a Metric', () => {
+ return ctx.ds
+ .getMetricMetadata(
+ '9935389e-9122-4ef9-95f9-1513dd24753f',
+ 'nodeapp',
+ 'microsoft.insights/components',
+ 'resource1',
+ 'default',
+ 'UsedCapacity'
+ )
+ .then((results) => {
+ expect(results.primaryAggType).toEqual('Total');
+ expect(results.supportedAggTypes.length).toEqual(6);
+ expect(results.supportedTimeGrains.length).toEqual(5); // 4 time grains from the API + auto
+ });
+ });
+ });
+
+ describe('When performing getMetricMetadata on metrics with dimensions', () => {
+ const response = {
+ value: [
+ {
+ name: {
+ value: 'Transactions',
+ localizedValue: 'Transactions',
+ },
+ unit: 'Count',
+ primaryAggregationType: 'Total',
+ supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'],
+ isDimensionRequired: false,
+ dimensions: [
+ {
+ value: 'ResponseType',
+ localizedValue: 'Response type',
+ },
+ {
+ value: 'GeoType',
+ localizedValue: 'Geo type',
+ },
+ {
+ value: 'ApiName',
+ localizedValue: 'API name',
+ },
+ ],
+ },
+ {
+ name: {
+ value: 'FreeCapacity',
+ localizedValue: 'Free capacity',
+ },
+ unit: 'CountPerSecond',
+ primaryAggregationType: 'Average',
+ supportedAggregationTypes: ['None', 'Average'],
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => {
+ const basePath = 'azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp';
+ const expected =
+ basePath +
+ '/providers/microsoft.insights/components/resource1' +
+ '/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default';
+ expect(path).toBe(expected);
+ return Promise.resolve(response);
+ });
+ });
+
+ it('should return dimensions for a Metric that has dimensions', () => {
+ return ctx.ds
+ .getMetricMetadata(
+ '9935389e-9122-4ef9-95f9-1513dd24753f',
+ 'nodeapp',
+ 'microsoft.insights/components',
+ 'resource1',
+ 'default',
+ 'Transactions'
+ )
+ .then((results: any) => {
+ expect(results.dimensions).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "label": "Response type",
+ "value": "ResponseType",
+ },
+ Object {
+ "label": "Geo type",
+ "value": "GeoType",
+ },
+ Object {
+ "label": "API name",
+ "value": "ApiName",
+ },
+ ]
+ `);
+ });
+ });
+
+ describe('When performing targetContainsTemplate', () => {
+ it('should return false when no variable is being used', () => {
+ const query = createMockQuery();
+ query.queryType = AzureQueryType.AzureMonitor;
+ expect(ctx.ds.targetContainsTemplate(query)).toEqual(false);
+ });
+
+ it('should return true when subscriptions field is using a variable', () => {
+ const query = createMockQuery();
+ const templateSrv = new TemplateSrv();
+ templateSrv.init([subscriptionsVariable]);
+
+ const ds = new AzureMonitorDatasource(ctx.instanceSettings, templateSrv);
+ query.queryType = AzureQueryType.AzureMonitor;
+ query.subscription = `$${subscriptionsVariable.name}`;
+ expect(ds.targetContainsTemplate(query)).toEqual(true);
+ });
+
+ it('should return false when a variable is used in a different part of the query', () => {
+ const query = createMockQuery();
+ const templateSrv = new TemplateSrv();
+ templateSrv.init([singleVariable]);
+
+ const ds = new AzureMonitorDatasource(ctx.instanceSettings, templateSrv);
+ query.queryType = AzureQueryType.AzureMonitor;
+ query.azureLogAnalytics = { resource: `$${singleVariable.name}` };
+ expect(ds.targetContainsTemplate(query)).toEqual(false);
+ });
+ });
+
+ it('should return an empty array for a Metric that does not have dimensions', () => {
+ return ctx.ds
+ .getMetricMetadata(
+ '9935389e-9122-4ef9-95f9-1513dd24753f',
+ 'nodeapp',
+ 'microsoft.insights/components',
+ 'resource1',
+ 'default',
+ 'FreeCapacity'
+ )
+ .then((results: any) => {
+ expect(results.dimensions.length).toEqual(0);
+ });
+ });
});
});
});
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts
index 9f876557e0f..b302061d483 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts
@@ -9,6 +9,9 @@ import TimegrainConverter from '../time_grain_converter';
import {
AzureDataSourceJsonData,
AzureMonitorMetricDefinitionsResponse,
+ AzureMonitorMetricNamespacesResponse,
+ AzureMonitorMetricNamesResponse,
+ AzureMonitorMetricsMetadataResponse,
AzureMonitorQuery,
AzureMonitorResourceGroupsResponse,
AzureQueryType,
@@ -21,6 +24,10 @@ import UrlBuilder from './url_builder';
const defaultDropdownValue = 'select';
+function hasValue(item?: string) {
+ return !!(item && item !== defaultDropdownValue);
+}
+
export default class AzureMonitorDatasource extends DataSourceWithBackend {
apiVersion = '2018-01-01';
apiPreviewVersion = '2017-12-01-preview';
@@ -40,7 +47,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend {
+ return this.getResource(`${this.resourcePath}/subscriptions?api-version=2019-03-01`).then((result: any) => {
return ResponseParser.parseSubscriptions(result);
});
}
getResourceGroups(subscriptionId: string) {
return this.getResource(
- `${this.resourcePath}/${subscriptionId}/resourceGroups?api-version=${this.listByResourceGroupApiVersion}`
+ `${this.resourcePath}/subscriptions/${subscriptionId}/resourceGroups?api-version=${this.listByResourceGroupApiVersion}`
).then((result: AzureMonitorResourceGroupsResponse) => {
return ResponseParser.parseResponseValues(result, 'name', 'name');
});
@@ -143,7 +150,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend {
return ResponseParser.parseResponseValues(result, 'type', 'type');
@@ -196,7 +203,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend {
+ return ResponseParser.parseResponseValues(result, 'name', 'properties.metricNamespaceName');
+ });
+ }
+
getMetricNames(
subscriptionId: string,
resourceGroup: string,
@@ -266,6 +286,20 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend {
+ return ResponseParser.parseResponseValues(result, 'name.localizedValue', 'name.value');
+ });
+ }
+
getMetricMetadata(
subscriptionId: string,
resourceGroup: string,
@@ -289,6 +323,20 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend {
+ return ResponseParser.parseMetadata(result, metricName);
+ });
+ }
+
async testDatasource(): Promise {
const validationError = this.validateDatasource();
if (validationError) {
@@ -296,7 +344,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend((response: any) => {
return {
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts
index 9ed28795078..4aa8b984548 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts
@@ -1,180 +1,209 @@
import UrlBuilder from './url_builder';
describe('AzureMonitorUrlBuilder', () => {
- describe('when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes', () => {
- it('should build the getMetricNamespaces url in the even longer format', () => {
- const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(
+ describe('when a resource uri is provided', () => {
+ it('builds a getMetricNamesnamespace url', () => {
+ const url = UrlBuilder.newBuildAzureMonitorGetMetricNamespacesUrl(
'',
- 'sub1',
- 'rg',
- 'Microsoft.NetApp/netAppAccounts/capacityPools/volumes',
- 'rn1/rn2/rn3',
+ '/subscriptions/sub/resource-uri/resource',
'2017-05-01-preview'
);
expect(url).toBe(
- '/sub1/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/' +
- 'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
+ '/subscriptions/sub/resource-uri/resource/providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
);
});
});
- describe('when metric definition is Microsoft.Sql/servers/databases', () => {
- it('should build the getMetricNamespaces url in the longer format', () => {
- const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(
+ describe('when a resource uri and metric namespace is provided', () => {
+ it('builds a getMetricNames url', () => {
+ const url = UrlBuilder.newBuildAzureMonitorGetMetricNamesUrl(
'',
- 'sub1',
- 'rg',
- 'Microsoft.Sql/servers/databases',
- 'rn1/rn2',
- '2017-05-01-preview'
- );
- expect(url).toBe(
- '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' +
- 'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
- );
- });
- });
-
- describe('when metric definition is Microsoft.Sql/servers', () => {
- it('should build the getMetricNamespaces url in the shorter format', () => {
- const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(
- '',
- 'sub1',
- 'rg',
+ '/subscriptions/sub/resource-uri/resource',
'Microsoft.Sql/servers',
- 'rn',
'2017-05-01-preview'
);
expect(url).toBe(
- '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' +
- 'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
+ '/subscriptions/sub/resource-uri/resource/providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=Microsoft.Sql%2Fservers'
);
});
});
- describe('when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes and the metricNamespace is default', () => {
- it('should build the getMetricNames url in the even longer format', () => {
- const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
- '',
- 'sub1',
- 'rg',
- 'Microsoft.NetApp/netAppAccounts/capacityPools/volumes',
- 'rn1/rn2/rn3',
- 'default',
- '2017-05-01-preview'
- );
- expect(url).toBe(
- '/sub1/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/' +
- 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
- );
+ describe('Legacy query object', () => {
+ describe('when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes', () => {
+ it('should build the getMetricNamespaces url in the even longer format', () => {
+ const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(
+ '',
+ 'sub1',
+ 'rg',
+ 'Microsoft.NetApp/netAppAccounts/capacityPools/volumes',
+ 'rn1/rn2/rn3',
+ '2017-05-01-preview'
+ );
+ expect(url).toBe(
+ '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/' +
+ 'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
+ );
+ });
});
- });
- describe('when metric definition is Microsoft.Sql/servers/databases and the metricNamespace is default', () => {
- it('should build the getMetricNames url in the longer format', () => {
- const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
- '',
- 'sub1',
- 'rg',
- 'Microsoft.Sql/servers/databases',
- 'rn1/rn2',
- 'default',
- '2017-05-01-preview'
- );
- expect(url).toBe(
- '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' +
- 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
- );
+ describe('when metric definition is Microsoft.Sql/servers/databases', () => {
+ it('should build the getMetricNamespaces url in the longer format', () => {
+ const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(
+ '',
+ 'sub1',
+ 'rg',
+ 'Microsoft.Sql/servers/databases',
+ 'rn1/rn2',
+ '2017-05-01-preview'
+ );
+ expect(url).toBe(
+ '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' +
+ 'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
+ );
+ });
});
- });
- describe('when metric definition is Microsoft.Sql/servers and the metricNamespace is default', () => {
- it('should build the getMetricNames url in the shorter format', () => {
- const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
- '',
- 'sub1',
- 'rg',
- 'Microsoft.Sql/servers',
- 'rn',
- 'default',
- '2017-05-01-preview'
- );
- expect(url).toBe(
- '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' +
- 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
- );
+ describe('when metric definition is Microsoft.Sql/servers', () => {
+ it('should build the getMetricNamespaces url in the shorter format', () => {
+ const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(
+ '',
+ 'sub1',
+ 'rg',
+ 'Microsoft.Sql/servers',
+ 'rn',
+ '2017-05-01-preview'
+ );
+ expect(url).toBe(
+ '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' +
+ 'providers/microsoft.insights/metricNamespaces?api-version=2017-05-01-preview'
+ );
+ });
});
- });
- describe('when metric definition is Microsoft.Storage/storageAccounts/blobServices and the metricNamespace is default', () => {
- it('should build the getMetricNames url in the longer format', () => {
- const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
- '',
- 'sub1',
- 'rg',
- 'Microsoft.Storage/storageAccounts/blobServices',
- 'rn1/default',
- 'default',
- '2017-05-01-preview'
- );
- expect(url).toBe(
- '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/' +
- 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
- );
+ describe('when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes and the metricNamespace is default', () => {
+ it('should build the getMetricNames url in the even longer format', () => {
+ const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
+ '',
+ 'sub1',
+ 'rg',
+ 'Microsoft.NetApp/netAppAccounts/capacityPools/volumes',
+ 'rn1/rn2/rn3',
+ 'default',
+ '2017-05-01-preview'
+ );
+ expect(url).toBe(
+ '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/' +
+ 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
+ );
+ });
});
- });
- describe('when metric definition is Microsoft.Storage/storageAccounts/fileServices and the metricNamespace is default', () => {
- it('should build the getMetricNames url in the longer format', () => {
- const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
- '',
- 'sub1',
- 'rg',
- 'Microsoft.Storage/storageAccounts/fileServices',
- 'rn1/default',
- 'default',
- '2017-05-01-preview'
- );
- expect(url).toBe(
- '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/' +
- 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
- );
+ describe('when metric definition is Microsoft.Sql/servers/databases and the metricNamespace is default', () => {
+ it('should build the getMetricNames url in the longer format', () => {
+ const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
+ '',
+ 'sub1',
+ 'rg',
+ 'Microsoft.Sql/servers/databases',
+ 'rn1/rn2',
+ 'default',
+ '2017-05-01-preview'
+ );
+ expect(url).toBe(
+ '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' +
+ 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
+ );
+ });
});
- });
- describe('when metric definition is Microsoft.Storage/storageAccounts/tableServices and the metricNamespace is default', () => {
- it('should build the getMetricNames url in the longer format', () => {
- const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
- '',
- 'sub1',
- 'rg',
- 'Microsoft.Storage/storageAccounts/tableServices',
- 'rn1/default',
- 'default',
- '2017-05-01-preview'
- );
- expect(url).toBe(
- '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/tableServices/default/' +
- 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
- );
+ describe('when metric definition is Microsoft.Sql/servers and the metricNamespace is default', () => {
+ it('should build the getMetricNames url in the shorter format', () => {
+ const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
+ '',
+ 'sub1',
+ 'rg',
+ 'Microsoft.Sql/servers',
+ 'rn',
+ 'default',
+ '2017-05-01-preview'
+ );
+ expect(url).toBe(
+ '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' +
+ 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
+ );
+ });
});
- });
- describe('when metric definition is Microsoft.Storage/storageAccounts/queueServices and the metricNamespace is default', () => {
- it('should build the getMetricNames url in the longer format', () => {
- const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
- '',
- 'sub1',
- 'rg',
- 'Microsoft.Storage/storageAccounts/queueServices',
- 'rn1/default',
- 'default',
- '2017-05-01-preview'
- );
- expect(url).toBe(
- '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/queueServices/default/' +
- 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
- );
+ describe('when metric definition is Microsoft.Storage/storageAccounts/blobServices and the metricNamespace is default', () => {
+ it('should build the getMetricNames url in the longer format', () => {
+ const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
+ '',
+ 'sub1',
+ 'rg',
+ 'Microsoft.Storage/storageAccounts/blobServices',
+ 'rn1/default',
+ 'default',
+ '2017-05-01-preview'
+ );
+ expect(url).toBe(
+ '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/' +
+ 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
+ );
+ });
+ });
+
+ describe('when metric definition is Microsoft.Storage/storageAccounts/fileServices and the metricNamespace is default', () => {
+ it('should build the getMetricNames url in the longer format', () => {
+ const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
+ '',
+ 'sub1',
+ 'rg',
+ 'Microsoft.Storage/storageAccounts/fileServices',
+ 'rn1/default',
+ 'default',
+ '2017-05-01-preview'
+ );
+ expect(url).toBe(
+ '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/' +
+ 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
+ );
+ });
+ });
+
+ describe('when metric definition is Microsoft.Storage/storageAccounts/tableServices and the metricNamespace is default', () => {
+ it('should build the getMetricNames url in the longer format', () => {
+ const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
+ '',
+ 'sub1',
+ 'rg',
+ 'Microsoft.Storage/storageAccounts/tableServices',
+ 'rn1/default',
+ 'default',
+ '2017-05-01-preview'
+ );
+ expect(url).toBe(
+ '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/tableServices/default/' +
+ 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
+ );
+ });
+ });
+
+ describe('when metric definition is Microsoft.Storage/storageAccounts/queueServices and the metricNamespace is default', () => {
+ it('should build the getMetricNames url in the longer format', () => {
+ const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
+ '',
+ 'sub1',
+ 'rg',
+ 'Microsoft.Storage/storageAccounts/queueServices',
+ 'rn1/default',
+ 'default',
+ '2017-05-01-preview'
+ );
+ expect(url).toBe(
+ '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/queueServices/default/' +
+ 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default'
+ );
+ });
});
});
});
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts
index a477dd23ef4..1d18c89a09d 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts
@@ -10,7 +10,7 @@ export default class UrlBuilder {
const metricDefinitionArray = metricDefinition.split('/');
const resourceNameArray = resourceName.split('/');
const provider = metricDefinitionArray.shift();
- const urlArray = [baseUrl, subscriptionId, 'resourceGroups', resourceGroup, 'providers', provider];
+ const urlArray = [baseUrl, 'subscriptions', subscriptionId, 'resourceGroups', resourceGroup, 'providers', provider];
for (const i in metricDefinitionArray) {
urlArray.push(metricDefinitionArray[i]);
urlArray.push(resourceNameArray[i]);
@@ -31,7 +31,7 @@ export default class UrlBuilder {
const metricDefinitionArray = metricDefinition.split('/');
const resourceNameArray = resourceName.split('/');
const provider = metricDefinitionArray.shift();
- const urlArray = [baseUrl, subscriptionId, 'resourceGroups', resourceGroup, 'providers', provider];
+ const urlArray = [baseUrl, 'subscriptions', subscriptionId, 'resourceGroups', resourceGroup, 'providers', provider];
for (const i in metricDefinitionArray) {
urlArray.push(metricDefinitionArray[i]);
urlArray.push(resourceNameArray[i]);
@@ -42,4 +42,20 @@ export default class UrlBuilder {
`&metricnamespace=${encodeURIComponent(metricNamespace)}`
);
}
+
+ static newBuildAzureMonitorGetMetricNamespacesUrl(baseUrl: string, resourceUri: string, apiVersion: string) {
+ return `${baseUrl}${resourceUri}/providers/microsoft.insights/metricNamespaces?api-version=${apiVersion}`;
+ }
+
+ static newBuildAzureMonitorGetMetricNamesUrl(
+ baseUrl: string,
+ resourceUri: string,
+ metricNamespace: string,
+ apiVersion: string
+ ) {
+ return (
+ `${baseUrl}${resourceUri}/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}` +
+ `&metricnamespace=${encodeURIComponent(metricNamespace)}`
+ );
+ }
}
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/LogsQueryEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/LogsQueryEditor.tsx
index d6ae263793d..4e63ac97ed5 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/LogsQueryEditor.tsx
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/LogsQueryEditor.tsx
@@ -2,10 +2,12 @@ import React from 'react';
import { AzureMonitorErrorish, AzureMonitorOption, AzureMonitorQuery } from '../../types';
import Datasource from '../../datasource';
import { Alert, InlineFieldRow } from '@grafana/ui';
+import { ResourceRowType } from '../ResourcePicker/types';
+import ResourceField from '../ResourceField';
import QueryField from './QueryField';
import FormatAsField from './FormatAsField';
-import ResourceField from './ResourceField';
import useMigrations from './useMigrations';
+import { setResource } from './setQueryValue';
interface LogsQueryEditorProps {
query: AzureMonitorQuery;
@@ -38,6 +40,14 @@ const LogsQueryEditor: React.FC = ({
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
+ selectableEntryTypes={[
+ ResourceRowType.Subscription,
+ ResourceRowType.ResourceGroup,
+ ResourceRowType.Resource,
+ ResourceRowType.Variable,
+ ]}
+ setResource={setResource}
+ resourceUri={query.azureLogAnalytics?.resource}
/>
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.test.ts
index 6de964895cd..2b25f47ad2c 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.test.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.test.ts
@@ -6,7 +6,6 @@ import { AzureMetricQuery, AzureMonitorOption, AzureMonitorQuery, AzureQueryType
import {
DataHook,
updateSubscriptions,
- useAsyncState,
useMetricNames,
useMetricNamespaces,
useResourceGroups,
@@ -15,75 +14,12 @@ import {
useSubscriptions,
} from './dataHooks';
-interface WaitableMock extends jest.Mock {
- waitToBeCalled(): Promise;
-}
-
const WAIT_OPTIONS = {
timeout: 1000,
};
-function createWaitableMock() {
- let resolve: Function;
-
- const mock = jest.fn() as WaitableMock;
- mock.mockImplementation(() => {
- resolve && resolve();
- });
-
- mock.waitToBeCalled = () => {
- return new Promise((_resolve) => (resolve = _resolve));
- };
-
- return mock;
-}
-
const opt = (text: string, value: string) => ({ text, value });
-describe('AzureMonitor: useAsyncState', () => {
- const MOCKED_RANDOM_VALUE = 0.42069;
-
- beforeEach(() => {
- jest.spyOn(global.Math, 'random').mockReturnValue(MOCKED_RANDOM_VALUE);
- });
-
- afterEach(() => {
- jest.spyOn(global.Math, 'random').mockRestore();
- });
-
- it('should return data from an async function', async () => {
- const apiCall = () => Promise.resolve(['a', 'b', 'c']);
- const setError = jest.fn();
-
- const { result, waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, []));
- await waitForNextUpdate();
-
- expect(result.current).toEqual(['a', 'b', 'c']);
- });
-
- it('should report errors through setError', async () => {
- const error = new Error();
- const apiCall = () => Promise.reject(error);
- const setError = createWaitableMock();
-
- const { result, waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, []));
- await Promise.race([waitForNextUpdate(), setError.waitToBeCalled()]);
-
- expect(result.current).toEqual([]);
- expect(setError).toHaveBeenCalledWith(MOCKED_RANDOM_VALUE, error);
- });
-
- it('should clear the error once the request is successful', async () => {
- const apiCall = () => Promise.resolve(['a', 'b', 'c']);
- const setError = createWaitableMock();
-
- const { waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, []));
- await Promise.race([waitForNextUpdate(), setError.waitToBeCalled()]);
-
- expect(setError).toHaveBeenCalledWith(MOCKED_RANDOM_VALUE, undefined);
- });
-});
-
interface TestScenario {
name: string;
hook: DataHook;
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts
index 6ffecfec1ea..c340e9929bb 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts
@@ -1,8 +1,9 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useState } from 'react';
import Datasource from '../../datasource';
import { AzureMonitorErrorish, AzureMonitorOption, AzureMonitorQuery } from '../../types';
import { hasOption, toOption } from '../../utils/common';
+import { useAsyncState } from '../../utils/useAsyncState';
import { setMetricNamespace, setSubscriptionID } from './setQueryValue';
export interface MetricMetadata {
@@ -26,29 +27,6 @@ export type DataHook = (
setError: SetErrorFn
) => AzureMonitorOption[];
-export function useAsyncState(asyncFn: () => Promise, setError: Function, dependencies: unknown[]) {
- // Use the lazy initial state functionality of useState to assign a random ID to the API call
- // to track where errors come from. See useLastError.
- const [errorSource] = useState(() => Math.random());
- const [value, setValue] = useState();
-
- const finalValue = useMemo(() => value ?? [], [value]);
-
- useEffect(() => {
- asyncFn()
- .then((results) => {
- setValue(results);
- setError(errorSource, undefined);
- })
- .catch((err) => {
- setError(errorSource, err);
- });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, dependencies);
-
- return finalValue;
-}
-
export const updateSubscriptions = (
query: AzureMonitorQuery,
subscriptionOptions: AzureMonitorOption[],
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/setQueryValue.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/setQueryValue.ts
index 108fe67cb1b..19c49b3d3dd 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/setQueryValue.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/setQueryValue.ts
@@ -1,5 +1,20 @@
import { AzureMetricDimension, AzureMonitorQuery } from '../../types';
+export function setResource(query: AzureMonitorQuery, resourceURI: string | undefined): AzureMonitorQuery {
+ return {
+ ...query,
+ azureMonitor: {
+ ...query.azureMonitor,
+ resourceUri: resourceURI,
+ metricNamespace: undefined,
+ metricName: undefined,
+ aggregation: undefined,
+ timeGrain: '',
+ dimensionFilters: [],
+ },
+ };
+}
+
export function setSubscriptionID(query: AzureMonitorQuery, subscriptionID: string): AzureMonitorQuery {
if (query.subscription === subscriptionID) {
return query;
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.test.tsx
new file mode 100644
index 00000000000..792d3b76123
--- /dev/null
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.test.tsx
@@ -0,0 +1,226 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { selectOptionInTest } from '@grafana/ui';
+
+import MetricsQueryEditor from './MetricsQueryEditor';
+import createMockQuery from '../../__mocks__/query';
+import createMockDatasource from '../../__mocks__/datasource';
+import createMockResourcePickerData from '../../__mocks__/resourcePickerData';
+import {
+ createMockResourceGroupsBySubscription,
+ createMockSubscriptions,
+ mockResourcesByResourceGroup,
+} from '../../__mocks__/resourcePickerRows';
+
+const variableOptionGroup = {
+ label: 'Template variables',
+ options: [],
+};
+
+const resourcePickerData = createMockResourcePickerData({
+ getSubscriptions: jest.fn().mockResolvedValue(createMockSubscriptions()),
+ getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue(createMockResourceGroupsBySubscription()),
+ getResourcesForResourceGroup: jest.fn().mockResolvedValue(mockResourcesByResourceGroup()),
+});
+
+describe('MetricsQueryEditor', () => {
+ const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView;
+ beforeEach(() => {
+ window.HTMLElement.prototype.scrollIntoView = function () {};
+ });
+ afterEach(() => {
+ window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
+ });
+
+ it('should render', async () => {
+ const mockDatasource = createMockDatasource({ resourcePickerData });
+
+ render(
+ {}}
+ setError={() => {}}
+ />
+ );
+
+ expect(await screen.findByTestId('azure-monitor-metrics-query-editor-with-resource-picker')).toBeInTheDocument();
+ });
+
+ it('should change resource when a resource is selected in the ResourcePicker', async () => {
+ const mockDatasource = createMockDatasource({ resourcePickerData });
+ const query = createMockQuery();
+ delete query?.azureMonitor?.resourceUri;
+ const onChange = jest.fn();
+
+ render(
+ {}}
+ />
+ );
+
+ const resourcePickerButton = await screen.findByRole('button', { name: 'Select a resource' });
+ expect(resourcePickerButton).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Expand Primary Subscription' })).not.toBeInTheDocument();
+ resourcePickerButton.click();
+
+ const subscriptionButton = await screen.findByRole('button', { name: 'Expand Primary Subscription' });
+ expect(subscriptionButton).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Expand A Great Resource Group' })).not.toBeInTheDocument();
+ subscriptionButton.click();
+
+ const resourceGroupButton = await screen.findByRole('button', { name: 'Expand A Great Resource Group' });
+ expect(resourceGroupButton).toBeInTheDocument();
+ expect(screen.queryByLabelText('web-server')).not.toBeInTheDocument();
+ resourceGroupButton.click();
+
+ const checkbox = await screen.findByLabelText('web-server');
+ expect(checkbox).toBeInTheDocument();
+ expect(checkbox).not.toBeChecked();
+ userEvent.click(checkbox);
+ expect(checkbox).toBeChecked();
+ userEvent.click(await screen.findByRole('button', { name: 'Apply' }));
+
+ expect(onChange).toBeCalledTimes(1);
+ expect(onChange).toBeCalledWith(
+ expect.objectContaining({
+ azureMonitor: expect.objectContaining({
+ resourceUri:
+ '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/web-server',
+ }),
+ })
+ );
+ });
+
+ it('should reset metric namespace, metric name, and aggregation fields after selecting a new resource when a valid query has already been set', async () => {
+ const mockDatasource = createMockDatasource({ resourcePickerData });
+ const query = createMockQuery();
+ const onChange = jest.fn();
+
+ render(
+ {}}
+ />
+ );
+
+ const resourcePickerButton = await screen.findByRole('button', { name: /grafanastaging/ });
+
+ expect(screen.getByText('Microsoft.Compute/virtualMachines')).toBeInTheDocument();
+ expect(screen.getByText('Metric A')).toBeInTheDocument();
+ expect(screen.getByText('Average')).toBeInTheDocument();
+
+ expect(resourcePickerButton).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Expand Primary Subscription' })).not.toBeInTheDocument();
+ resourcePickerButton.click();
+
+ const subscriptionButton = await screen.findByRole('button', { name: 'Expand Dev Subscription' });
+ expect(subscriptionButton).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Expand Development 3' })).not.toBeInTheDocument();
+ subscriptionButton.click();
+
+ const resourceGroupButton = await screen.findByRole('button', { name: 'Expand Development 3' });
+ expect(resourceGroupButton).toBeInTheDocument();
+ expect(screen.queryByLabelText('db-server')).not.toBeInTheDocument();
+ resourceGroupButton.click();
+
+ const checkbox = await screen.findByLabelText('db-server');
+ expect(checkbox).toBeInTheDocument();
+ expect(checkbox).not.toBeChecked();
+ userEvent.click(checkbox);
+ expect(checkbox).toBeChecked();
+ userEvent.click(await screen.findByRole('button', { name: 'Apply' }));
+
+ expect(onChange).toBeCalledTimes(1);
+ expect(onChange).toBeCalledWith(
+ expect.objectContaining({
+ azureMonitor: expect.objectContaining({
+ resourceUri:
+ '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/db-server',
+ metricNamespace: undefined,
+ metricName: undefined,
+ aggregation: undefined,
+ timeGrain: '',
+ dimensionFilters: [],
+ }),
+ })
+ );
+ });
+
+ it('should change the metric name when selected', async () => {
+ const mockDatasource = createMockDatasource({ resourcePickerData });
+ const onChange = jest.fn();
+ const mockQuery = createMockQuery();
+ mockDatasource.azureMonitorDatasource.newGetMetricNames = jest.fn().mockResolvedValue([
+ {
+ value: 'metric-a',
+ text: 'Metric A',
+ },
+ {
+ value: 'metric-b',
+ text: 'Metric B',
+ },
+ ]);
+
+ render(
+ {}}
+ />
+ );
+
+ const metrics = await screen.findByLabelText('Metric');
+ expect(metrics).toBeInTheDocument();
+ await selectOptionInTest(metrics, 'Metric B');
+
+ expect(onChange).toHaveBeenLastCalledWith({
+ ...mockQuery,
+ azureMonitor: {
+ ...mockQuery.azureMonitor,
+ metricName: 'metric-b',
+ aggregation: undefined,
+ timeGrain: '',
+ },
+ });
+ });
+
+ it('should change the aggregation type when selected', async () => {
+ const mockDatasource = createMockDatasource({ resourcePickerData });
+ const onChange = jest.fn();
+ const mockQuery = createMockQuery();
+
+ render(
+ {}}
+ />
+ );
+
+ const aggregation = await screen.findByLabelText('Aggregation');
+ expect(aggregation).toBeInTheDocument();
+ await selectOptionInTest(aggregation, 'Maximum');
+
+ expect(onChange).toHaveBeenLastCalledWith({
+ ...mockQuery,
+ azureMonitor: {
+ ...mockQuery.azureMonitor,
+ aggregation: 'Maximum',
+ },
+ });
+ });
+});
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.tsx
index 595d3203fd7..97e6bb3b0e1 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.tsx
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/MetricsQueryEditor.tsx
@@ -1,9 +1,113 @@
import React from 'react';
+import { InlineFieldRow } from '@grafana/ui';
+import AggregationField from '../MetricsQueryEditor/AggregationField';
+import MetricNameField from '../MetricsQueryEditor/MetricNameField';
+import MetricNamespaceField from '../MetricsQueryEditor/MetricNamespaceField';
+import TimeGrainField from '../MetricsQueryEditor/TimeGrainField';
+import DimensionFields from '../MetricsQueryEditor/DimensionFields';
+import TopField from '../MetricsQueryEditor/TopField';
+import LegendFormatField from '../MetricsQueryEditor/LegendFormatField';
+import ResourceField from '../ResourceField';
+import { ResourceRowType } from '../ResourcePicker/types';
+import type Datasource from '../../datasource';
+import type { AzureMonitorQuery, AzureMonitorOption, AzureMonitorErrorish } from '../../types';
+import { useMetricNames, useMetricNamespaces, useMetricMetadata } from './dataHooks';
+import { setResource } from '../MetricsQueryEditor/setQueryValue';
-interface MetricsQueryEditorProps {}
+interface MetricsQueryEditorProps {
+ query: AzureMonitorQuery;
+ datasource: Datasource;
+ onChange: (newQuery: AzureMonitorQuery) => void;
+ variableOptionGroup: { label: string; options: AzureMonitorOption[] };
+ setError: (source: string, error: AzureMonitorErrorish | undefined) => void;
+}
-const MetricsQueryEditor: React.FC = ({}) => {
- return New Query Editor
;
+const MetricsQueryEditor: React.FC = ({
+ query,
+ datasource,
+ variableOptionGroup,
+ onChange,
+ setError,
+}) => {
+ const metricsMetadata = useMetricMetadata(query, datasource, onChange);
+ const metricNamespaces = useMetricNamespaces(query, datasource, onChange, setError);
+ const metricNames = useMetricNames(query, datasource, onChange, setError);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
};
export default MetricsQueryEditor;
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.test.ts
new file mode 100644
index 00000000000..7a784885c91
--- /dev/null
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.test.ts
@@ -0,0 +1,165 @@
+import { renderHook } from '@testing-library/react-hooks';
+
+import createMockDatasource from '../../__mocks__/datasource';
+import Datasource from '../../datasource';
+import { AzureMetricQuery, AzureMonitorOption, AzureMonitorQuery, AzureQueryType } from '../../types';
+import { DataHook } from '../MetricsQueryEditor/dataHooks';
+import { useMetricNames, useMetricNamespaces } from './dataHooks';
+
+const WAIT_OPTIONS = {
+ timeout: 1000,
+};
+
+const opt = (text: string, value: string) => ({ text, value });
+
+interface TestScenario {
+ name: string;
+ hook: DataHook;
+
+ // For convenience, only need to define the azureMonitor part of the query for some tests
+ emptyQueryPartial: AzureMetricQuery;
+ customProperties: AzureMetricQuery;
+ topLevelCustomProperties?: Partial;
+
+ expectedCustomPropertyResults?: Array>;
+ expectedOptions: AzureMonitorOption[];
+}
+
+describe('AzureMonitor: metrics dataHooks', () => {
+ const bareQuery = {
+ refId: 'A',
+ queryType: AzureQueryType.AzureMonitor,
+ subscription: 'sub-abc-123',
+ };
+
+ const testTable: TestScenario[] = [
+ {
+ name: 'useMetricNames',
+ hook: useMetricNames,
+ emptyQueryPartial: {
+ resourceUri:
+ '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
+ metricNamespace: 'azure/vm',
+ },
+ customProperties: {
+ resourceUri:
+ '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
+ metricNamespace: 'azure/vm',
+ metricName: 'metric-$ENVIRONMENT',
+ },
+ expectedOptions: [
+ {
+ label: 'Percentage CPU',
+ value: 'percentage-cpu',
+ },
+ {
+ label: 'Free memory',
+ value: 'free-memory',
+ },
+ ],
+ expectedCustomPropertyResults: [
+ { label: 'Percentage CPU', value: 'percentage-cpu' },
+ { label: 'Free memory', value: 'free-memory' },
+ { label: 'metric-$ENVIRONMENT', value: 'metric-$ENVIRONMENT' },
+ ],
+ },
+
+ {
+ name: 'useMetricNamespaces',
+ hook: useMetricNamespaces,
+ emptyQueryPartial: {
+ resourceUri:
+ '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
+ metricNamespace: 'azure/vm',
+ },
+ customProperties: {
+ resourceUri:
+ '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana',
+ metricNamespace: 'azure/vm-$ENVIRONMENT',
+ metricName: 'metric-name',
+ },
+ expectedOptions: [
+ {
+ label: 'Compute Virtual Machine',
+ value: 'azure/vmc',
+ },
+ {
+ label: 'Database NS',
+ value: 'azure/dbns',
+ },
+ {
+ label: 'azure/vm',
+ value: 'azure/vm',
+ },
+ ],
+ expectedCustomPropertyResults: [
+ { label: 'Compute Virtual Machine', value: 'azure/vmc' },
+ { label: 'Database NS', value: 'azure/dbns' },
+ { label: 'azure/vm-$ENVIRONMENT', value: 'azure/vm-$ENVIRONMENT' },
+ ],
+ },
+ ];
+
+ let datasource: Datasource;
+ let onChange: jest.Mock;
+ let setError: jest.Mock;
+
+ beforeEach(() => {
+ onChange = jest.fn();
+ setError = jest.fn();
+
+ datasource = createMockDatasource();
+ datasource.getVariables = jest.fn().mockReturnValue(['$sub', '$rg', '$rt', '$variable']);
+
+ datasource.azureMonitorDatasource.getSubscriptions = jest
+ .fn()
+ .mockResolvedValue([opt('sub-abc-123', 'sub-abc-123')]);
+
+ datasource.getResourceGroups = jest
+ .fn()
+ .mockResolvedValue([
+ opt('Web App - Production', 'web-app-production'),
+ opt('Web App - Development', 'web-app-development'),
+ ]);
+
+ datasource.getMetricDefinitions = jest
+ .fn()
+ .mockResolvedValue([opt('Virtual Machine', 'azure/vm'), opt('Database', 'azure/db')]);
+
+ datasource.getResourceNames = jest
+ .fn()
+ .mockResolvedValue([opt('Web server', 'web-server'), opt('Job server', 'job-server')]);
+
+ datasource.azureMonitorDatasource.newGetMetricNames = jest
+ .fn()
+ .mockResolvedValue([opt('Percentage CPU', 'percentage-cpu'), opt('Free memory', 'free-memory')]);
+
+ datasource.azureMonitorDatasource.newGetMetricNamespaces = jest
+ .fn()
+ .mockResolvedValue([opt('Compute Virtual Machine', 'azure/vmc'), opt('Database NS', 'azure/dbns')]);
+ });
+ describe.each(testTable)('scenario %#: $name', (scenario) => {
+ it('returns values', async () => {
+ const query = {
+ ...bareQuery,
+ azureMonitor: scenario.emptyQueryPartial,
+ };
+ const { result, waitForNextUpdate } = renderHook(() => scenario.hook(query, datasource, onChange, setError));
+ await waitForNextUpdate(WAIT_OPTIONS);
+
+ expect(result.current).toEqual(scenario.expectedOptions);
+ });
+
+ it('adds custom properties as a valid option', async () => {
+ const query = {
+ ...bareQuery,
+ azureMonitor: scenario.customProperties,
+ ...scenario.topLevelCustomProperties,
+ };
+ const { result, waitForNextUpdate } = renderHook(() => scenario.hook(query, datasource, onChange, setError));
+ await waitForNextUpdate(WAIT_OPTIONS);
+
+ expect(result.current).toEqual(scenario.expectedCustomPropertyResults);
+ });
+ });
+});
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.ts
new file mode 100644
index 00000000000..a8c11ebd38b
--- /dev/null
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/NewMetricsQueryEditor/dataHooks.ts
@@ -0,0 +1,144 @@
+import { useEffect, useState } from 'react';
+
+import Datasource from '../../datasource';
+import { AzureMonitorOption, AzureMonitorQuery } from '../../types';
+import { toOption } from '../../utils/common';
+import { useAsyncState } from '../../utils/useAsyncState';
+import { DataHook } from '../MetricsQueryEditor/dataHooks';
+import { setMetricNamespace } from '../MetricsQueryEditor/setQueryValue';
+
+export interface MetricMetadata {
+ aggOptions: AzureMonitorOption[];
+ timeGrains: AzureMonitorOption[];
+ dimensions: AzureMonitorOption[];
+ isLoading: boolean;
+
+ // These two properties are only used within the hook, and not elsewhere
+ supportedAggTypes: string[];
+ primaryAggType: string | undefined;
+}
+
+type OnChangeFn = (newQuery: AzureMonitorQuery) => void;
+
+export const useMetricNamespaces: DataHook = (query, datasource, onChange, setError) => {
+ const { metricNamespace, resourceUri } = query.azureMonitor ?? {};
+
+ const metricNamespaces = useAsyncState(
+ async () => {
+ if (!resourceUri) {
+ return;
+ }
+
+ const results = await datasource.azureMonitorDatasource.newGetMetricNamespaces(resourceUri);
+ const options = formatOptions(results, metricNamespace);
+
+ // Do some cleanup of the query state if need be
+ if (!metricNamespace && options.length) {
+ onChange(setMetricNamespace(query, options[0].value));
+ }
+
+ return options;
+ },
+ setError,
+ [resourceUri]
+ );
+
+ return metricNamespaces;
+};
+
+export const useMetricNames: DataHook = (query, datasource, onChange, setError) => {
+ const { metricNamespace, metricName, resourceUri } = query.azureMonitor ?? {};
+
+ return useAsyncState(
+ async () => {
+ if (!(metricNamespace && resourceUri)) {
+ return;
+ }
+
+ const results = await datasource.azureMonitorDatasource.newGetMetricNames(resourceUri, metricNamespace);
+ const options = formatOptions(results, metricName);
+
+ return options;
+ },
+ setError,
+ [resourceUri, metricNamespace]
+ );
+};
+
+const defaultMetricMetadata: MetricMetadata = {
+ aggOptions: [],
+ timeGrains: [],
+ dimensions: [],
+ isLoading: false,
+ supportedAggTypes: [],
+ primaryAggType: undefined,
+};
+
+export const useMetricMetadata = (query: AzureMonitorQuery, datasource: Datasource, onChange: OnChangeFn) => {
+ const [metricMetadata, setMetricMetadata] = useState(defaultMetricMetadata);
+
+ const { resourceUri, metricNamespace, metricName, aggregation, timeGrain } = query.azureMonitor ?? {};
+
+ // Fetch new metric metadata when the fields change
+ useEffect(() => {
+ if (!(resourceUri && metricNamespace && metricName)) {
+ setMetricMetadata(defaultMetricMetadata);
+ return;
+ }
+
+ datasource.azureMonitorDatasource
+ .newGetMetricMetadata(resourceUri, metricNamespace, metricName)
+ .then((metadata) => {
+ // TODO: Move the aggregationTypes and timeGrain defaults into `getMetricMetadata`
+ const aggregations = (metadata.supportedAggTypes || [metadata.primaryAggType]).map((v) => ({
+ label: v,
+ value: v,
+ }));
+
+ setMetricMetadata({
+ aggOptions: aggregations,
+ timeGrains: metadata.supportedTimeGrains,
+ dimensions: metadata.dimensions,
+ isLoading: false,
+ supportedAggTypes: metadata.supportedAggTypes ?? [],
+ primaryAggType: metadata.primaryAggType,
+ });
+ });
+ }, [datasource, resourceUri, metricNamespace, metricName]);
+
+ // Update the query state in response to the meta data changing
+ useEffect(() => {
+ const newAggregation = aggregation || metricMetadata.primaryAggType;
+ const newTimeGrain = timeGrain || 'auto';
+
+ if (newAggregation !== aggregation || newTimeGrain !== timeGrain) {
+ onChange({
+ ...query,
+ azureMonitor: {
+ ...query.azureMonitor,
+ aggregation: newAggregation,
+ timeGrain: newTimeGrain,
+ },
+ });
+ }
+ }, [onChange, metricMetadata, aggregation, timeGrain, query]);
+
+ return metricMetadata;
+};
+
+function formatOptions(
+ rawResults: Array<{
+ text: string;
+ value: string;
+ }>,
+ selectedValue?: string
+) {
+ const options = rawResults.map(toOption);
+
+ // account for custom values that might have been set in json file like ones crafted with a template variable (ex: "cloud-datasource-resource-$Environment")
+ if (selectedValue && !options.find((option) => option.value === selectedValue)) {
+ options.push({ label: selectedValue, value: selectedValue });
+ }
+
+ return options;
+}
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx
index 85382626a38..dbc618bc6bf 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx
@@ -99,7 +99,15 @@ const EditorForQueryType: React.FC = ({
switch (query.queryType) {
case AzureQueryType.AzureMonitor:
if (config.featureToggles.azureMonitorResourcePickerForMetrics) {
- return ;
+ return (
+
+ );
}
return (
= ({ query, datasource, onQueryChange }) => {
+interface ResourceFieldProps extends AzureQueryEditorFieldProps {
+ setResource: (query: AzureMonitorQuery, resourceURI?: string) => AzureMonitorQuery;
+ selectableEntryTypes: ResourceRowType[];
+ resourceUri?: string;
+}
+
+const ResourceField: React.FC = ({
+ query,
+ datasource,
+ onQueryChange,
+ setResource,
+ selectableEntryTypes,
+ resourceUri,
+}) => {
const styles = useStyles2(getStyles);
- const { resource } = query.azureLogAnalytics ?? {};
const [pickerIsOpen, setPickerIsOpen] = useState(false);
const handleOpenPicker = useCallback(() => {
@@ -44,7 +55,7 @@ const ResourceField: React.FC = ({ query, datasource
onQueryChange(setResource(query, resourceURI));
closePicker();
},
- [closePicker, onQueryChange, query]
+ [closePicker, onQueryChange, query, setResource]
);
return (
@@ -60,21 +71,16 @@ const ResourceField: React.FC = ({ query, datasource
>
-
+
>
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourceField/index.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourceField/index.tsx
new file mode 100644
index 00000000000..5e5d95b4814
--- /dev/null
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourceField/index.tsx
@@ -0,0 +1 @@
+export { default } from './ResourceField';
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/query.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/query.ts
index 22c208d3cea..86c9a2df88c 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/query.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/query.ts
@@ -36,6 +36,7 @@ export interface AzureMonitorQuery extends DeprecatedAzureMonitorQuery {
* Azure Monitor Metrics sub-query properties
*/
export interface AzureMetricQuery {
+ resourceUri?: string;
resourceGroup?: string;
/** Resource type */
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/types.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/types.ts
index 4e91064b7e0..9a6666fcf77 100644
--- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/types.ts
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types/types.ts
@@ -95,6 +95,23 @@ export interface AzureMonitorMetricMetadataItem {
metricAvailabilities?: AzureMonitorMetricAvailabilityMetadata[];
}
+export interface AzureMonitorMetricNamespacesResponse {
+ value: AzureMonitorMetricNamespaceItem[];
+}
+
+export interface AzureMonitorMetricNamespaceItem {
+ name: string;
+ properties: { metricNamespacename: string };
+}
+
+export interface AzureMonitorMetricNamesResponse {
+ value: AzureMonitorMetricNameItem[];
+}
+
+export interface AzureMonitorMetricNameItem {
+ name: { value: string; localizedValue: string };
+}
+
export interface AzureMonitorMetricAvailabilityMetadata {
timeGrain: string;
retention: string;
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/useAsyncState.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/useAsyncState.test.ts
new file mode 100644
index 00000000000..ddbfb2b5cc0
--- /dev/null
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/useAsyncState.test.ts
@@ -0,0 +1,65 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { useAsyncState } from './useAsyncState';
+
+interface WaitableMock extends jest.Mock {
+ waitToBeCalled(): Promise;
+}
+
+function createWaitableMock() {
+ let resolve: Function;
+
+ const mock = jest.fn() as WaitableMock;
+ mock.mockImplementation(() => {
+ resolve && resolve();
+ });
+
+ mock.waitToBeCalled = () => {
+ return new Promise((_resolve) => (resolve = _resolve));
+ };
+
+ return mock;
+}
+
+describe('useAsyncState', () => {
+ const MOCKED_RANDOM_VALUE = 0.42069;
+
+ beforeEach(() => {
+ jest.spyOn(global.Math, 'random').mockReturnValue(MOCKED_RANDOM_VALUE);
+ });
+
+ afterEach(() => {
+ jest.spyOn(global.Math, 'random').mockRestore();
+ });
+
+ it('should return data from an async function', async () => {
+ const apiCall = () => Promise.resolve(['a', 'b', 'c']);
+ const setError = jest.fn();
+
+ const { result, waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, []));
+ await waitForNextUpdate();
+
+ expect(result.current).toEqual(['a', 'b', 'c']);
+ });
+
+ it('should report errors through setError', async () => {
+ const error = new Error();
+ const apiCall = () => Promise.reject(error);
+ const setError = createWaitableMock();
+
+ const { result, waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, []));
+ await Promise.race([waitForNextUpdate(), setError.waitToBeCalled()]);
+
+ expect(result.current).toEqual([]);
+ expect(setError).toHaveBeenCalledWith(MOCKED_RANDOM_VALUE, error);
+ });
+
+ it('should clear the error once the request is successful', async () => {
+ const apiCall = () => Promise.resolve(['a', 'b', 'c']);
+ const setError = createWaitableMock();
+
+ const { waitForNextUpdate } = renderHook(() => useAsyncState(apiCall, setError, []));
+ await Promise.race([waitForNextUpdate(), setError.waitToBeCalled()]);
+
+ expect(setError).toHaveBeenCalledWith(MOCKED_RANDOM_VALUE, undefined);
+ });
+});
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/useAsyncState.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/useAsyncState.ts
new file mode 100644
index 00000000000..fa84157bfc6
--- /dev/null
+++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/useAsyncState.ts
@@ -0,0 +1,24 @@
+import { useEffect, useMemo, useState } from 'react';
+
+export function useAsyncState(asyncFn: () => Promise, setError: Function, dependencies: unknown[]) {
+ // Use the lazy initial state functionality of useState to assign a random ID to the API call
+ // to track where errors come from. See useLastError.
+ const [errorSource] = useState(() => Math.random());
+ const [value, setValue] = useState();
+
+ const finalValue = useMemo(() => value ?? [], [value]);
+
+ useEffect(() => {
+ asyncFn()
+ .then((results) => {
+ setValue(results);
+ setError(errorSource, undefined);
+ })
+ .catch((err) => {
+ setError(errorSource, err);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, dependencies);
+
+ return finalValue;
+}
diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts
index f8439136914..944d62bbe07 100644
--- a/public/app/plugins/datasource/loki/datasource.ts
+++ b/public/app/plugins/datasource/loki/datasource.ts
@@ -40,8 +40,8 @@ import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { convertToWebSocketUrl } from 'app/core/utils/explore';
import {
lokiResultsToTableModel,
- lokiStreamResultToDataFrame,
lokiStreamsToDataFrames,
+ lokiStreamsToRawDataFrame,
processRangeQueryResponse,
} from './result_transformer';
import { transformBackendResult } from './backendResultTransformer';
@@ -54,7 +54,6 @@ import {
LokiRangeQueryRequest,
LokiResultType,
LokiStreamResponse,
- LokiStreamResult,
} from './types';
import { LiveStreams, LokiLiveTarget } from './live_streams';
import LanguageProvider from './language_provider';
@@ -310,8 +309,7 @@ export class LokiDatasource
responseListLength,
maxDataPoints,
this.instanceSettings.jsonData,
- (options as DataQueryRequest).scopedVars,
- (options as DataQueryRequest).reverse
+ (options as DataQueryRequest).scopedVars
)
)
);
@@ -538,9 +536,7 @@ export class LokiDatasource
}),
switchMap((res) =>
of({
- data: res.data
- ? res.data.data.result.map((stream: LokiStreamResult) => lokiStreamResultToDataFrame(stream, reverse))
- : [],
+ data: res.data ? [lokiStreamsToRawDataFrame(res.data.data.result, reverse)] : [],
})
)
)
@@ -667,33 +663,31 @@ export class LokiDatasource
const splitKeys: string[] = tagKeys.split(',').filter((v: string) => v !== '');
for (const frame of data) {
- const labels: { [key: string]: string } = {};
- for (const field of frame.fields) {
- if (field.labels) {
- for (const [key, value] of Object.entries(field.labels)) {
- labels[key] = String(value).trim();
- }
- }
- }
-
- const tags: string[] = [
- ...new Set(
- Object.entries(labels).reduce((acc: string[], [key, val]) => {
- if (val === '') {
- return acc;
- }
- if (splitKeys.length && !splitKeys.includes(key)) {
- return acc;
- }
- acc.push.apply(acc, [val]);
- return acc;
- }, [])
- ),
- ];
-
- const view = new DataFrameView<{ ts: string; line: string }>(frame);
+ const view = new DataFrameView<{ ts: string; line: string; labels: Labels }>(frame);
view.forEach((row) => {
+ const { labels } = row;
+
+ const maybeDuplicatedTags = Object.entries(labels)
+ .map(([key, val]) => [key, val.trim()]) // trim all label-values
+ .filter(([key, val]) => {
+ if (val === '') {
+ // remove empty
+ return false;
+ }
+
+ // if tags are specified, remove label if does not match tags
+ if (splitKeys.length && !splitKeys.includes(key)) {
+ return false;
+ }
+
+ return true;
+ })
+ .map(([key, val]) => val); // keep only the label-value
+
+ // remove duplicates
+ const tags = Array.from(new Set(maybeDuplicatedTags));
+
annotations.push({
time: new Date(row.ts).valueOf(),
title: renderLegendFormat(titleFormat, labels),
diff --git a/public/app/plugins/datasource/loki/live_streams.ts b/public/app/plugins/datasource/loki/live_streams.ts
index be252116786..a2a68ec9fd6 100644
--- a/public/app/plugins/datasource/loki/live_streams.ts
+++ b/public/app/plugins/datasource/loki/live_streams.ts
@@ -30,11 +30,11 @@ export class LiveStreams {
}
const data = new CircularDataFrame({ capacity: target.size });
- data.addField({ name: 'ts', type: FieldType.time, config: { displayName: 'Time' } });
- data.addField({ name: 'tsNs', type: FieldType.time, config: { displayName: 'Time ns' } });
- data.addField({ name: 'line', type: FieldType.string }).labels = parseLabels(target.query);
data.addField({ name: 'labels', type: FieldType.other }); // The labels for each line
+ data.addField({ name: 'ts', type: FieldType.time, config: { displayName: 'Time' } });
+ data.addField({ name: 'line', type: FieldType.string }).labels = parseLabels(target.query);
data.addField({ name: 'id', type: FieldType.string });
+ data.addField({ name: 'tsNs', type: FieldType.time, config: { displayName: 'Time ns' } });
data.meta = { ...data.meta, preferredVisualisationType: 'logs' };
data.refId = target.refId;
diff --git a/public/app/plugins/datasource/loki/result_transformer.test.ts b/public/app/plugins/datasource/loki/result_transformer.test.ts
index f0e64b5e8ef..db35da689a6 100644
--- a/public/app/plugins/datasource/loki/result_transformer.test.ts
+++ b/public/app/plugins/datasource/loki/result_transformer.test.ts
@@ -64,18 +64,20 @@ describe('loki result transformer', () => {
jest.clearAllMocks();
});
- describe('lokiStreamResultToDataFrame', () => {
+ describe('lokiStreamsToRawDataFrame', () => {
it('converts streams to series', () => {
- const data = streamResult.map((stream) => ResultTransformer.lokiStreamResultToDataFrame(stream));
+ const data = ResultTransformer.lokiStreamsToRawDataFrame(streamResult);
- expect(data.length).toBe(2);
- expect(data[0].fields[1].labels!['foo']).toEqual('bar');
- expect(data[0].fields[0].values.get(0)).toEqual('2020-01-24T09:19:22.021Z');
- expect(data[0].fields[1].values.get(0)).toEqual(streamResult[0].values[0][1]);
- expect(data[0].fields[2].values.get(0)).toEqual('4b79cb43-81ce-52f7-b1e9-a207fff144dc');
- expect(data[1].fields[0].values.get(0)).toEqual('2020-01-24T09:19:22.031Z');
- expect(data[1].fields[1].values.get(0)).toEqual(streamResult[1].values[0][1]);
- expect(data[1].fields[2].values.get(0)).toEqual('73d144f6-57f2-5a45-a49c-eb998e2006b1');
+ expect(data.fields[0].values.get(0)).toStrictEqual({ foo: 'bar' });
+ expect(data.fields[1].values.get(0)).toEqual('2020-01-24T09:19:22.021Z');
+ expect(data.fields[2].values.get(0)).toEqual(streamResult[0].values[0][1]);
+ expect(data.fields[3].values.get(0)).toEqual(streamResult[0].values[0][0]);
+ expect(data.fields[4].values.get(0)).toEqual('4b79cb43-81ce-52f7-b1e9-a207fff144dc');
+ expect(data.fields[0].values.get(1)).toStrictEqual({ bar: 'foo' });
+ expect(data.fields[1].values.get(1)).toEqual('2020-01-24T09:19:22.031Z');
+ expect(data.fields[2].values.get(1)).toEqual(streamResult[1].values[0][1]);
+ expect(data.fields[3].values.get(1)).toEqual(streamResult[1].values[0][0]);
+ expect(data.fields[4].values.get(1)).toEqual('73d144f6-57f2-5a45-a49c-eb998e2006b1');
});
it('should always generate unique ids for logs', () => {
@@ -100,20 +102,19 @@ describe('loki result transformer', () => {
},
];
- const data = streamResultWithDuplicateLogs.map((stream) => ResultTransformer.lokiStreamResultToDataFrame(stream));
+ const data = ResultTransformer.lokiStreamsToRawDataFrame(streamResultWithDuplicateLogs);
- expect(data[0].fields[2].values.get(0)).toEqual('b48fe7dc-36aa-5d37-bfba-087ef810d8fa');
- expect(data[0].fields[2].values.get(1)).toEqual('b48fe7dc-36aa-5d37-bfba-087ef810d8fa_1');
- expect(data[0].fields[2].values.get(2)).not.toEqual('b48fe7dc-36aa-5d37-bfba-087ef810d8fa_2');
- expect(data[0].fields[2].values.get(3)).toEqual('b48fe7dc-36aa-5d37-bfba-087ef810d8fa_2');
- expect(data[1].fields[2].values.get(0)).not.toEqual('b48fe7dc-36aa-5d37-bfba-087ef810d8fa_3');
+ expect(data.fields[4].values.get(0)).toEqual('b48fe7dc-36aa-5d37-bfba-087ef810d8fa');
+ expect(data.fields[4].values.get(1)).toEqual('b48fe7dc-36aa-5d37-bfba-087ef810d8fa_1');
+ expect(data.fields[4].values.get(2)).not.toEqual('b48fe7dc-36aa-5d37-bfba-087ef810d8fa_2');
+ expect(data.fields[4].values.get(3)).toEqual('b48fe7dc-36aa-5d37-bfba-087ef810d8fa_2');
+ expect(data.fields[4].values.get(4)).not.toEqual('b48fe7dc-36aa-5d37-bfba-087ef810d8fa_3');
});
it('should append refId to the unique ids if refId is provided', () => {
- const data = streamResult.map((stream) => ResultTransformer.lokiStreamResultToDataFrame(stream, false, 'B'));
- expect(data.length).toBe(2);
- expect(data[0].fields[2].values.get(0)).toEqual('4b79cb43-81ce-52f7-b1e9-a207fff144dc_B');
- expect(data[1].fields[2].values.get(0)).toEqual('73d144f6-57f2-5a45-a49c-eb998e2006b1_B');
+ const data = ResultTransformer.lokiStreamsToRawDataFrame(streamResult, false, 'B');
+ expect(data.fields[4].values.get(0)).toEqual('4b79cb43-81ce-52f7-b1e9-a207fff144dc_B');
+ expect(data.fields[4].values.get(1)).toEqual('73d144f6-57f2-5a45-a49c-eb998e2006b1_B');
});
});
@@ -159,11 +160,11 @@ describe('loki result transformer', () => {
};
const data = new CircularDataFrame({ capacity: 1 });
- data.addField({ name: 'ts', type: FieldType.time, config: { displayName: 'Time' } });
- data.addField({ name: 'tsNs', type: FieldType.time, config: { displayName: 'Time ns' } });
- data.addField({ name: 'line', type: FieldType.string }).labels = { job: 'grafana' };
data.addField({ name: 'labels', type: FieldType.other });
+ data.addField({ name: 'ts', type: FieldType.time, config: { displayName: 'Time' } });
+ data.addField({ name: 'line', type: FieldType.string }).labels = { job: 'grafana' };
data.addField({ name: 'id', type: FieldType.string });
+ data.addField({ name: 'tsNs', type: FieldType.time, config: { displayName: 'Time ns' } });
ResultTransformer.appendResponseToBufferedData(tailResponse, data);
expect(data.get(0)).toEqual({
@@ -196,11 +197,11 @@ describe('loki result transformer', () => {
};
const data = new CircularDataFrame({ capacity: 6 });
- data.addField({ name: 'ts', type: FieldType.time, config: { displayName: 'Time' } });
- data.addField({ name: 'tsNs', type: FieldType.time, config: { displayName: 'Time ns' } });
- data.addField({ name: 'line', type: FieldType.string }).labels = { job: 'grafana' };
data.addField({ name: 'labels', type: FieldType.other });
+ data.addField({ name: 'ts', type: FieldType.time, config: { displayName: 'Time' } });
+ data.addField({ name: 'line', type: FieldType.string }).labels = { job: 'grafana' };
data.addField({ name: 'id', type: FieldType.string });
+ data.addField({ name: 'tsNs', type: FieldType.time, config: { displayName: 'Time ns' } });
data.refId = 'C';
ResultTransformer.appendResponseToBufferedData(tailResponse, data);
@@ -306,7 +307,7 @@ describe('enhanceDataFrame', () => {
[1, 1000],
[0, 2000],
[1, 4000],
- [null, 7000],
+ [NaN, 7000],
[Infinity, 8000],
[-Infinity, 9000],
]);
diff --git a/public/app/plugins/datasource/loki/result_transformer.ts b/public/app/plugins/datasource/loki/result_transformer.ts
index 34aa2e5900b..31760ee0d08 100644
--- a/public/app/plugins/datasource/loki/result_transformer.ts
+++ b/public/app/plugins/datasource/loki/result_transformer.ts
@@ -43,15 +43,10 @@ import { renderLegendFormat } from '../prometheus/legend';
const UUID_NAMESPACE = '6ec946da-0f49-47a8-983a-1d76d17e7c92';
/**
- * Transforms LokiStreamResult structure into a dataFrame. Used when doing standard queries and newer version of Loki.
+ * Transforms LokiStreamResult structure into a dataFrame. Used when doing standard queries
*/
-export function lokiStreamResultToDataFrame(stream: LokiStreamResult, reverse?: boolean, refId?: string): DataFrame {
- const labels: Labels = stream.stream;
- const labelsString = Object.entries(labels)
- .map(([key, val]) => `${key}="${val}"`)
- .sort()
- .join('');
-
+export function lokiStreamsToRawDataFrame(streams: LokiStreamResult[], reverse?: boolean, refId?: string): DataFrame {
+ const labels = new ArrayVector<{}>([]);
const times = new ArrayVector([]);
const timesNs = new ArrayVector([]);
const lines = new ArrayVector([]);
@@ -60,12 +55,21 @@ export function lokiStreamResultToDataFrame(stream: LokiStreamResult, reverse?:
// We need to store and track all used uids to ensure that uids are unique
const usedUids: { string?: number } = {};
- for (const [ts, line] of stream.values) {
- // num ns epoch in string, we convert it to iso string here so it matches old format
- times.add(new Date(parseInt(ts.slice(0, -6), 10)).toISOString());
- timesNs.add(ts);
- lines.add(line);
- uids.add(createUid(ts, labelsString, line, usedUids, refId));
+ for (const stream of streams) {
+ const streamLabels: Labels = stream.stream;
+ const labelsString = Object.entries(streamLabels)
+ .map(([key, val]) => `${key}="${val}"`)
+ .sort()
+ .join('');
+
+ for (const [ts, line] of stream.values) {
+ labels.add(streamLabels);
+ // num ns epoch in string, we convert it to iso string here so it matches old format
+ times.add(new Date(parseInt(ts.slice(0, -6), 10)).toISOString());
+ timesNs.add(ts);
+ lines.add(line);
+ uids.add(createUid(ts, labelsString, line, usedUids, refId));
+ }
}
return constructDataFrame(times, timesNs, lines, uids, labels, reverse, refId);
@@ -79,17 +83,18 @@ function constructDataFrame(
timesNs: ArrayVector,
lines: ArrayVector,
uids: ArrayVector,
- labels: Labels,
+ labels: ArrayVector<{}>,
reverse?: boolean,
refId?: string
) {
const dataFrame = {
refId,
fields: [
+ { name: 'labels', type: FieldType.other, config: {}, values: labels },
{ name: 'ts', type: FieldType.time, config: { displayName: 'Time' }, values: times }, // Time
- { name: 'line', type: FieldType.string, config: {}, values: lines, labels }, // Line - needs to be the first field with string type
- { name: 'id', type: FieldType.string, config: {}, values: uids },
+ { name: 'line', type: FieldType.string, config: {}, values: lines }, // Line - needs to be the first field with string type
{ name: 'tsNs', type: FieldType.time, config: { displayName: 'Time ns' }, values: timesNs }, // Time
+ { name: 'id', type: FieldType.string, config: {}, values: uids },
],
length: times.length,
};
@@ -128,11 +133,11 @@ export function appendResponseToBufferedData(response: LokiTailResponse, data: M
}
}
- const tsField = data.fields[0];
- const tsNsField = data.fields[1];
+ const labelsField = data.fields[0];
+ const tsField = data.fields[1];
const lineField = data.fields[2];
- const labelsField = data.fields[3];
- const idField = data.fields[4];
+ const idField = data.fields[3];
+ const tsNsField = data.fields[4];
// We are comparing used ids only within the received stream. This could be a problem if the same line + labels + nanosecond timestamp came in 2 separate batches.
// As this is very unlikely, and the result would only affect live-tailing css animation we have decided to not compare all received uids from data param as this would slow down processing.
@@ -209,10 +214,6 @@ export function lokiPointsToTimeseriesPoints(data: Array<[number, string]>): Tim
for (const [time, value] of data) {
let datapointValue: TimeSeriesValue = parsePrometheusFormatSampleValue(value);
- if (isNaN(datapointValue)) {
- datapointValue = null;
- }
-
const timestamp = time * 1000;
datapoints.push([datapointValue, timestamp]);
@@ -350,20 +351,12 @@ export function lokiStreamsToDataFrames(
preferredVisualisationType: 'logs',
};
- const series: DataFrame[] = data.map((stream) => {
- const dataFrame = lokiStreamResultToDataFrame(stream, reverse, target.refId);
- enhanceDataFrame(dataFrame, config);
+ const dataFrame = lokiStreamsToRawDataFrame(data, reverse, target.refId);
+ enhanceDataFrame(dataFrame, config);
- if (meta.custom && dataFrame.fields.some((f) => f.labels && Object.keys(f.labels).some((l) => l === '__error__'))) {
- meta.custom.error = 'Error when parsing some of the logs';
- }
-
- return {
- ...dataFrame,
- refId: target.refId,
- meta,
- };
- });
+ if (meta.custom && dataFrame.fields.some((f) => f.labels && Object.keys(f.labels).some((l) => l === '__error__'))) {
+ meta.custom.error = 'Error when parsing some of the logs';
+ }
if (stats.length && !data.length) {
return [
@@ -376,7 +369,13 @@ export function lokiStreamsToDataFrames(
];
}
- return series;
+ return [
+ {
+ ...dataFrame,
+ refId: target.refId,
+ meta,
+ },
+ ];
}
/**
@@ -521,13 +520,12 @@ export function processRangeQueryResponse(
responseListLength: number,
limit: number,
config: LokiOptions,
- scopedVars: ScopedVars,
- reverse = false
+ scopedVars: ScopedVars
) {
switch (response.data.resultType) {
case LokiResultType.Stream:
return of({
- data: lokiStreamsToDataFrames(response as LokiStreamResponse, target, limit, config, reverse),
+ data: lokiStreamsToDataFrames(response as LokiStreamResponse, target, limit, config),
key: `${target.refId}_log`,
});
diff --git a/public/app/plugins/datasource/loki/types.ts b/public/app/plugins/datasource/loki/types.ts
index c72954e7c38..da887ec18a9 100644
--- a/public/app/plugins/datasource/loki/types.ts
+++ b/public/app/plugins/datasource/loki/types.ts
@@ -35,7 +35,6 @@ export interface LokiQuery extends DataQuery {
expr: string;
query?: string;
format?: string;
- reverse?: boolean;
legendFormat?: string;
valueWithRefId?: boolean;
maxLines?: number;
diff --git a/public/app/plugins/datasource/prometheus/result_transformer.test.ts b/public/app/plugins/datasource/prometheus/result_transformer.test.ts
index d61dee2fbad..b7087c11d1e 100644
--- a/public/app/plugins/datasource/prometheus/result_transformer.test.ts
+++ b/public/app/plugins/datasource/prometheus/result_transformer.test.ts
@@ -251,6 +251,71 @@ describe('Prometheus Result Transformer', () => {
expect(series.data[0].fields[2].values.toArray()).toEqual([10, 0, 30]);
expect(series.data[0].fields[3].values.toArray()).toEqual([10, 0, 10]);
});
+
+ it('Retains exemplar frames when data returned is a heatmap', () => {
+ const options = {
+ targets: [
+ {
+ format: 'heatmap',
+ refId: 'A',
+ },
+ ],
+ } as unknown as DataQueryRequest;
+ const response = {
+ state: 'Done',
+ data: [
+ new MutableDataFrame({
+ refId: 'A',
+ fields: [
+ { name: 'Time', type: FieldType.time, values: [6, 5, 4] },
+ {
+ name: 'Value',
+ type: FieldType.number,
+ values: [10, 10, 0],
+ labels: { le: '1' },
+ },
+ ],
+ }),
+ new MutableDataFrame({
+ refId: 'A',
+ name: 'exemplar',
+ meta: {
+ custom: {
+ resultType: 'exemplar',
+ },
+ },
+ fields: [
+ { name: 'Time', type: FieldType.time, values: [6, 5, 4, 3, 2, 1] },
+ {
+ name: 'Value',
+ type: FieldType.number,
+ values: [30, 10, 40, 90, 14, 21],
+ labels: { le: '6' },
+ },
+ {
+ name: 'Test',
+ type: FieldType.string,
+ values: ['hello', 'doctor', 'name', 'continue', 'yesterday', 'tomorrow'],
+ labels: { le: '6' },
+ },
+ ],
+ }),
+ ],
+ } as unknown as DataQueryResponse;
+
+ const series = transformV2(response, options, {});
+ expect(series.data[0].fields.length).toEqual(2);
+ expect(series.data.length).toEqual(2);
+ expect(series.data[1].fields[2].values.toArray()).toEqual([
+ 'hello',
+ 'doctor',
+ 'name',
+ 'continue',
+ 'yesterday',
+ 'tomorrow',
+ ]);
+ expect(series.data[1].fields.length).toEqual(3);
+ });
});
describe('transformDFToTable', () => {
it('transforms dataFrame with response length 1 to table dataFrame', () => {
diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts
index d3d5b1f9595..62b8d2f3422 100644
--- a/public/app/plugins/datasource/prometheus/result_transformer.ts
+++ b/public/app/plugins/datasource/prometheus/result_transformer.ts
@@ -72,16 +72,8 @@ export function transformV2(
const [tableFrames, framesWithoutTable] = partition(response.data, (df) => isTableResult(df, request));
const processedTableFrames = transformDFToTable(tableFrames);
- const [heatmapResults, framesWithoutTableAndHeatmaps] = partition(framesWithoutTable, (df) =>
- isHeatmapResult(df, request)
- );
-
- const processedHeatmapFrames = mergeHeatmapFrames(
- transformToHistogramOverTime(heatmapResults.sort(sortSeriesByLabel))
- );
-
- const [exemplarFrames, framesWithoutTableHeatmapsAndExemplars] = partition(
- framesWithoutTableAndHeatmaps,
+ const [exemplarFrames, framesWithoutTableAndExemplars] = partition(
+ framesWithoutTable,
(df) => df.meta?.custom?.resultType === 'exemplar'
);
@@ -103,6 +95,15 @@ export function transformV2(
return { ...dataFrame, meta: { ...dataFrame.meta, dataTopic: DataTopic.Annotations } };
});
+ const [heatmapResults, framesWithoutTableHeatmapsAndExemplars] = partition(
+ framesWithoutTableAndExemplars,
+ (df) => isHeatmapResult(df, request)
+ );
+
+ const processedHeatmapFrames = mergeHeatmapFrames(
+ transformToHistogramOverTime(heatmapResults.sort(sortSeriesByLabel))
+ );
+
// Everything else is processed as time_series result and graph preferredVisualisationType
const otherFrames = framesWithoutTableHeatmapsAndExemplars.map((dataFrame) => {
const df = {
diff --git a/public/app/plugins/datasource/testdata/QueryEditor.tsx b/public/app/plugins/datasource/testdata/QueryEditor.tsx
index 1c762c22730..a10116b875f 100644
--- a/public/app/plugins/datasource/testdata/QueryEditor.tsx
+++ b/public/app/plugins/datasource/testdata/QueryEditor.tsx
@@ -21,6 +21,7 @@ import { defaultStreamQuery } from './runStreams';
import { CSVFileEditor } from './components/CSVFileEditor';
import { CSVContentEditor } from './components/CSVContentEditor';
import { USAQueryEditor, usaQueryModes } from './components/USAQueryEditor';
+import ErrorEditor from './components/ErrorEditor';
const showLabelsFor = ['random_walk', 'predictable_pulse'];
const endpoints = [
@@ -69,6 +70,7 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props)
[scenarioList, query]
);
const scenarioId = currentScenario?.id;
+ const description = currentScenario?.description;
const onScenarioChange = (item: SelectableValue) => {
const scenario = scenarioList?.find((sc) => sc.id === item.value);
@@ -161,7 +163,7 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props)
.sort((a, b) => a.label.localeCompare(b.label)),
[scenarioList]
);
- const showLabels = useMemo(() => showLabelsFor.includes(query.scenarioId), [query]);
+ const showLabels = useMemo(() => showLabelsFor.includes(query.scenarioId ?? ''), [query]);
if (loading) {
return null;
@@ -287,6 +289,9 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props)
{scenarioId === 'node_graph' && (
onChange({ ...query, nodes: val })} query={query} />
)}
+ {scenarioId === 'server_error_500' && }
+
+ {description && {description}
}
>
);
};
diff --git a/public/app/plugins/datasource/testdata/components/ErrorEditor.tsx b/public/app/plugins/datasource/testdata/components/ErrorEditor.tsx
new file mode 100644
index 00000000000..a8447118a88
--- /dev/null
+++ b/public/app/plugins/datasource/testdata/components/ErrorEditor.tsx
@@ -0,0 +1,36 @@
+import { InlineField, InlineFieldRow, Select } from '@grafana/ui';
+import React from 'react';
+import { EditorProps } from '../QueryEditor';
+
+const ERROR_OPTIONS = [
+ {
+ label: 'Server panic',
+ value: 'server_panic',
+ },
+ {
+ label: 'Frontend exception',
+ value: 'frontend_exception',
+ },
+ {
+ label: 'Frontend observable',
+ value: 'frontend_observable',
+ },
+];
+
+const FrontendErrorQueryEditor: React.FC = ({ query, onChange }) => {
+ return (
+
+
+ {
+ onChange({ ...query, errorType: v.value });
+ }}
+ />
+
+
+ );
+};
+
+export default FrontendErrorQueryEditor;
diff --git a/public/app/plugins/datasource/testdata/datasource.ts b/public/app/plugins/datasource/testdata/datasource.ts
index 09b8cc133b8..b75d23016be 100644
--- a/public/app/plugins/datasource/testdata/datasource.ts
+++ b/public/app/plugins/datasource/testdata/datasource.ts
@@ -1,4 +1,4 @@
-import { from, merge, Observable, of } from 'rxjs';
+import { from, merge, Observable, of, throwError } from 'rxjs';
import { delay } from 'rxjs/operators';
import {
@@ -68,6 +68,12 @@ export class TestDataDataSource extends DataSourceWithBackend {
case 'raw_frame':
streams.push(this.rawFrameQuery(target, options));
break;
+ case 'server_error_500':
+ // this now has an option where it can return/throw an error from the frontend.
+ // if it doesn't, send it to the backend where it might panic there :)
+ const query = this.serverErrorQuery(target, options);
+ query ? streams.push(query) : backendQueries.push(target);
+ break;
// Unusable since 7, removed in 8
case 'manual_entry': {
let csvContent = 'Time,Value\n';
@@ -81,10 +87,6 @@ export class TestDataDataSource extends DataSourceWithBackend {
}
default:
- if (target.alias) {
- target.alias = this.templateSrv.replace(target.alias, options.scopedVars);
- }
-
backendQueries.push(target);
}
}
@@ -105,7 +107,24 @@ export class TestDataDataSource extends DataSourceWithBackend {
}
resolveTemplateVariables(query: TestDataQuery, scopedVars: ScopedVars) {
- query.labels = this.templateSrv.replace(query.labels!, scopedVars);
+ if (query.labels) {
+ query.labels = this.templateSrv.replace(query.labels, scopedVars);
+ }
+ if (query.alias) {
+ query.alias = this.templateSrv.replace(query.alias, scopedVars);
+ }
+ if (query.scenarioId) {
+ query.scenarioId = this.templateSrv.replace(query.scenarioId, scopedVars);
+ }
+ if (query.stringInput) {
+ query.stringInput = this.templateSrv.replace(query.stringInput, scopedVars);
+ }
+ if (query.csvContent) {
+ query.csvContent = this.templateSrv.replace(query.csvContent, scopedVars);
+ }
+ if (query.rawFrameContent) {
+ query.rawFrameContent = this.templateSrv.replace(query.rawFrameContent, scopedVars);
+ }
}
annotationDataTopicTest(target: TestDataQuery, req: DataQueryRequest): Observable {
@@ -139,10 +158,13 @@ export class TestDataDataSource extends DataSourceWithBackend {
}
getQueryDisplayText(query: TestDataQuery) {
+ const scenario = query.scenarioId ?? 'Default scenario';
+
if (query.alias) {
- return query.scenarioId + ' as ' + query.alias;
+ return scenario + ' as ' + query.alias;
}
- return query.scenarioId;
+
+ return scenario;
}
testDatasource() {
@@ -202,6 +224,29 @@ export class TestDataDataSource extends DataSourceWithBackend {
return of({ data: [], error: ex }).pipe(delay(100));
}
}
+
+ serverErrorQuery(
+ target: TestDataQuery,
+ options: DataQueryRequest
+ ): Observable | null {
+ const { errorType } = target;
+ console.log("we're here!", target);
+
+ if (errorType === 'server_panic') {
+ return null;
+ }
+
+ const stringInput = target.stringInput ?? '';
+ if (stringInput === '') {
+ if (errorType === 'frontend_exception') {
+ throw new Error('Scenario threw an exception in the frontend because the input was empty.');
+ } else {
+ return throwError(() => new Error('Scenario returned an error because the input was empty.'));
+ }
+ }
+
+ return null;
+ }
}
function runGrafanaAPI(target: TestDataQuery, req: DataQueryRequest): Observable {
diff --git a/public/app/plugins/datasource/testdata/types.ts b/public/app/plugins/datasource/testdata/types.ts
index f34442fa571..56ce9f569f4 100644
--- a/public/app/plugins/datasource/testdata/types.ts
+++ b/public/app/plugins/datasource/testdata/types.ts
@@ -4,11 +4,12 @@ export interface Scenario {
id: string;
name: string;
stringInput: string;
+ description?: string;
}
export interface TestDataQuery extends DataQuery {
alias?: string;
- scenarioId: string;
+ scenarioId?: string;
stringInput?: string;
stream?: StreamingQuery;
pulseWave?: PulseWaveQuery;
@@ -22,6 +23,7 @@ export interface TestDataQuery extends DataQuery {
csvContent?: string;
rawFrameContent?: string;
usa?: USAQuery;
+ errorType?: 'server_panic' | 'frontend_exception' | 'frontend_observable';
}
export interface NodesQuery {
diff --git a/public/app/plugins/panel/pluginlist/PluginList.tsx b/public/app/plugins/panel/pluginlist/PluginList.tsx
deleted file mode 100644
index c3af90ac740..00000000000
--- a/public/app/plugins/panel/pluginlist/PluginList.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import React from 'react';
-import { useAsync } from 'react-use';
-import { css, cx } from '@emotion/css';
-import { GrafanaTheme, PanelProps, PluginMeta, PluginType } from '@grafana/data';
-import { CustomScrollbar, ModalsController, stylesFactory, Tooltip, useStyles } from '@grafana/ui';
-import { contextSrv } from 'app/core/services/context_srv';
-import { getBackendSrv } from 'app/core/services/backend_srv';
-import { UpdatePluginModal } from './components/UpdatePluginModal';
-
-export function PluginList(props: PanelProps) {
- const pluginState = useAsync(async () => {
- const plugins: PluginMeta[] = await getBackendSrv().get('api/plugins', { embedded: 0, core: 0 });
- return [
- { header: 'Installed Apps', list: plugins.filter((p) => p.type === PluginType.app), type: PluginType.app },
- { header: 'Installed Panels', list: plugins.filter((p) => p.type === PluginType.panel), type: PluginType.panel },
- {
- header: 'Installed Datasources',
- list: plugins.filter((p) => p.type === PluginType.datasource),
- type: PluginType.datasource,
- },
- ];
- }, []);
-
- const styles = useStyles(getStyles);
- const isAdmin = contextSrv.user.isGrafanaAdmin;
-
- if (pluginState.loading || pluginState.value === undefined) {
- return null;
- }
-
- return (
-
-
- {pluginState.value.map((category) => (
-
- ))}
-
-
- );
-}
-
-const getStyles = stylesFactory((theme: GrafanaTheme) => ({
- pluginList: css`
- display: flex;
- flex-direction: column;
- `,
- section: css`
- display: flex;
- flex-direction: column;
- &:not(:last-of-type) {
- margin-bottom: 16px;
- }
- `,
- sectionHeader: css`
- color: ${theme.colors.textWeak};
- margin-bottom: ${theme.spacing.d};
- `,
- image: css`
- width: 17px;
- margin-right: ${theme.spacing.xxs};
- `,
- title: css`
- margin-right: calc(${theme.spacing.d} / 3);
- `,
- version: css`
- font-size: ${theme.typography.size.sm};
- color: ${theme.colors.textWeak};
- `,
- item: css`
- display: flex;
- justify-content: flex-start;
- align-items: center;
- cursor: pointer;
- margin: ${theme.spacing.xxs};
- padding: ${theme.spacing.sm};
- background: ${theme.colors.dashboardBg};
- border-radius: ${theme.border.radius.md};
- `,
- message: css`
- margin-left: auto;
- font-size: ${theme.typography.size.sm};
- `,
- messageEnable: css`
- color: ${theme.colors.linkExternal};
- &:hover {
- border-bottom: ${theme.border.width.sm} solid ${theme.colors.linkExternal};
- }
- `,
- messageUpdate: css`
- &:hover {
- border-bottom: ${theme.border.width.sm} solid ${theme.colors.text};
- }
- `,
- messageNoUpdate: css`
- color: ${theme.colors.textWeak};
- `,
- noneInstalled: css`
- color: ${theme.colors.textWeak};
- font-size: ${theme.typography.size.sm};
- `,
- emphasis: css`
- font-weight: ${theme.typography.weight.semibold};
- font-style: normal;
- color: ${theme.colors.textWeak};
- `,
-}));
diff --git a/public/app/plugins/panel/pluginlist/README.md b/public/app/plugins/panel/pluginlist/README.md
deleted file mode 100644
index 2e0d851d7e5..00000000000
--- a/public/app/plugins/panel/pluginlist/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# Plugin List Panel - Native Plugin
-
-The Plugin List plans shows the installed plugins for your Grafana instance and is **included** with Grafana. It is used on the default Home dashboard.
diff --git a/public/app/plugins/panel/pluginlist/components/UpdatePluginModal.tsx b/public/app/plugins/panel/pluginlist/components/UpdatePluginModal.tsx
deleted file mode 100644
index b48d99529eb..00000000000
--- a/public/app/plugins/panel/pluginlist/components/UpdatePluginModal.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import React from 'react';
-import { Modal, stylesFactory, useStyles } from '@grafana/ui';
-import { GrafanaTheme } from '@grafana/data';
-import { css } from '@emotion/css';
-
-interface Props {
- pluginName: string;
- pluginID: string;
- onConfirm?: () => void;
- onDismiss?: () => void;
-}
-
-export function UpdatePluginModal({ pluginName, pluginID, onDismiss }: Props) {
- const styles = useStyles(getStyles);
-
- return (
-
-
-
Type the following on the command line to update {pluginName}.
-
- grafana-cli plugins update {pluginID}
-
-
- Check out {pluginName} on Grafana.com for README and
- changelog. If you do not have access to the command line, ask your Grafana administator.
-
-
-
-
- Pro tip : To update all plugins at once, type{' '}
- grafana-cli plugins update-all on the command line.
-
-
- );
-}
-
-const getStyles = stylesFactory((theme: GrafanaTheme) => ({
- small: css`
- font-size: ${theme.typography.size.sm};
- font-weight: ${theme.typography.weight.regular};
- `,
- codeSmall: css`
- font-size: ${theme.typography.size.xs};
- padding: ${theme.spacing.xxs};
- margin: 0 ${theme.spacing.xxs};
- `,
- container: css`
- margin-bottom: calc(${theme.spacing.d} * 2.5);
- `,
- updateAllTip: css`
- color: ${theme.colors.textWeak};
- font-size: ${theme.typography.size.sm};
- `,
- inlineLogo: css`
- vertical-align: sub;
- margin-right: calc(${theme.spacing.d} / 3);
- width: ${theme.spacing.md};
- `,
-}));
diff --git a/public/app/plugins/panel/pluginlist/img/icn-dashlist-panel.svg b/public/app/plugins/panel/pluginlist/img/icn-dashlist-panel.svg
deleted file mode 100644
index bdc6c33039c..00000000000
--- a/public/app/plugins/panel/pluginlist/img/icn-dashlist-panel.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/app/plugins/panel/pluginlist/module.tsx b/public/app/plugins/panel/pluginlist/module.tsx
deleted file mode 100644
index 86f97e04b5d..00000000000
--- a/public/app/plugins/panel/pluginlist/module.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-import { PanelPlugin } from '@grafana/data';
-import { PluginList } from './PluginList';
-
-export const plugin = new PanelPlugin(PluginList);
diff --git a/public/app/plugins/panel/pluginlist/plugin.json b/public/app/plugins/panel/pluginlist/plugin.json
deleted file mode 100644
index c2faa3b6dd7..00000000000
--- a/public/app/plugins/panel/pluginlist/plugin.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "type": "panel",
- "name": "Plugin list",
- "id": "pluginlist",
-
- "skipDataQuery": true,
-
- "info": {
- "description": "Plugin List for Grafana",
- "author": {
- "name": "Grafana Labs",
- "url": "https://grafana.com"
- },
- "logos": {
- "small": "img/icn-dashlist-panel.svg",
- "large": "img/icn-dashlist-panel.svg"
- }
- }
-}
diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts
index 851c4d883e3..03275629e9b 100644
--- a/public/app/store/configureStore.ts
+++ b/public/app/store/configureStore.ts
@@ -1,10 +1,10 @@
-import { configureStore as reduxConfigureStore } from '@reduxjs/toolkit';
+import { configureStore as reduxConfigureStore, MiddlewareArray } from '@reduxjs/toolkit';
import { setStore } from './store';
import { StoreState } from 'app/types/store';
import { addReducer, createRootReducer } from '../core/reducers/root';
import { buildInitialState } from '../core/reducers/navModel';
-import { ThunkMiddlewareFor } from '@reduxjs/toolkit/src/getDefaultMiddleware';
import { AnyAction } from 'redux';
+import { ThunkMiddleware } from 'redux-thunk';
export function addRootReducer(reducers: any) {
// this is ok now because we add reducers before configureStore is called
@@ -14,11 +14,7 @@ export function addRootReducer(reducers: any) {
}
export function configureStore(initialState?: Partial) {
- const store = reduxConfigureStore<
- StoreState,
- AnyAction,
- ReadonlyArray>
- >({
+ const store = reduxConfigureStore]>>({
reducer: createRootReducer(),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }),
diff --git a/public/test/core/redux/reduxTester.ts b/public/test/core/redux/reduxTester.ts
index cd334b7d4c8..c730b6f96a6 100644
--- a/public/test/core/redux/reduxTester.ts
+++ b/public/test/core/redux/reduxTester.ts
@@ -56,7 +56,7 @@ export const reduxTester = (args?: ReduxTesterArguments): ReduxTes
const preloadedState = args?.preloadedState ?? ({} as unknown as PreloadedState>>);
const debug = args?.debug ?? false;
- let store: EnhancedStore | null = null;
+ let store: EnhancedStore | null = null;
const defaultMiddleware = getDefaultMiddleware({
thunk: false,
diff --git a/scripts/drone/pipelines/release.star b/scripts/drone/pipelines/release.star
index de794ca93c1..0e10c9892fa 100644
--- a/scripts/drone/pipelines/release.star
+++ b/scripts/drone/pipelines/release.star
@@ -339,8 +339,9 @@ def publish_artifacts_step(mode):
'image': publish_image,
'environment': {
'GCP_KEY': from_secret('gcp_key'),
+ 'PRERELEASE_BUCKET': from_secret('prerelease_bucket'),
},
- 'commands': ['./bin/grabpl artifacts publish {}--tag ${{TAG}} --src-bucket grafana-prerelease'.format(security)],
+ 'commands': ['./bin/grabpl artifacts publish {}--tag ${{TAG}} --src-bucket $${{PRERELEASE_BUCKET}}'.format(security)],
'depends_on': ['grabpl'],
}
diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star
index f373ccf00ca..2eea9aa70d3 100644
--- a/scripts/drone/steps/lib.star
+++ b/scripts/drone/steps/lib.star
@@ -273,7 +273,7 @@ def store_storybook_step(edition, ver_mode, trigger=None):
'printenv GCP_KEY | base64 -d > /tmp/gcpkey.json',
'gcloud auth activate-service-account --key-file=/tmp/gcpkey.json',
] + [
- 'gsutil -m rm -r gs://$${{PRERELEASE_BUCKET}}/artifacts/storybook/{} && gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${{PRERELEASE_BUCKET}}/artifacts/storybook/{}'.format(
+ 'gsutil -m rm -r gs://$${{PRERELEASE_BUCKET}}/artifacts/storybook/{} || true && gsutil -m cp -r ./packages/grafana-ui/dist/storybook/* gs://$${{PRERELEASE_BUCKET}}/artifacts/storybook/{}'.format(
c, c)
for c in channels
])
diff --git a/yarn.lock b/yarn.lock
index ace1dddad49..568fe87e119 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3939,9 +3939,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "@grafana-plugins/input-datasource@workspace:plugins-bundled/internal/input-datasource"
dependencies:
- "@grafana/data": 8.5.0-pre
- "@grafana/toolkit": 8.5.0-pre
- "@grafana/ui": 8.5.0-pre
+ "@grafana/data": 9.0.0-pre
+ "@grafana/toolkit": 9.0.0-pre
+ "@grafana/ui": 9.0.0-pre
"@types/jest": 26.0.15
"@types/lodash": 4.14.149
"@types/react": 17.0.30
@@ -3982,18 +3982,18 @@ __metadata:
languageName: node
linkType: hard
-"@grafana/data@8.5.0-pre, @grafana/data@workspace:*, @grafana/data@workspace:packages/grafana-data":
+"@grafana/data@9.0.0-pre, @grafana/data@workspace:*, @grafana/data@workspace:packages/grafana-data":
version: 0.0.0-use.local
resolution: "@grafana/data@workspace:packages/grafana-data"
dependencies:
"@braintree/sanitize-url": 6.0.0
- "@grafana/schema": 8.5.0-pre
+ "@grafana/schema": 9.0.0-pre
"@grafana/tsconfig": ^1.2.0-rc1
"@rollup/plugin-commonjs": 21.0.2
"@rollup/plugin-json": 4.1.0
"@rollup/plugin-node-resolve": 13.1.3
"@swc/helpers": 0.3.8
- "@testing-library/dom": 8.12.0
+ "@testing-library/dom": 8.13.0
"@testing-library/jest-dom": 5.16.2
"@testing-library/react": 12.1.4
"@testing-library/react-hooks": 7.0.2
@@ -4040,7 +4040,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@grafana/e2e-selectors@8.5.0-pre, @grafana/e2e-selectors@workspace:*, @grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors":
+"@grafana/e2e-selectors@9.0.0-pre, @grafana/e2e-selectors@workspace:*, @grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors":
version: 0.0.0-use.local
resolution: "@grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors"
dependencies:
@@ -4064,7 +4064,7 @@ __metadata:
"@babel/core": 7.17.8
"@babel/preset-env": 7.16.11
"@cypress/webpack-preprocessor": 5.11.1
- "@grafana/e2e-selectors": 8.5.0-pre
+ "@grafana/e2e-selectors": 9.0.0-pre
"@grafana/tsconfig": ^1.2.0-rc1
"@mochajs/json-file-reporter": ^1.2.0
"@rollup/plugin-commonjs": 21.0.2
@@ -4094,7 +4094,7 @@ __metadata:
tslib: 2.3.1
typescript: 4.4.4
uuid: 8.3.2
- webpack: 5.70.0
+ webpack: 5.72.0
yaml: ^1.8.3
bin:
grafana-e2e: bin/grafana-e2e.js
@@ -4171,14 +4171,14 @@ __metadata:
version: 0.0.0-use.local
resolution: "@grafana/runtime@workspace:packages/grafana-runtime"
dependencies:
- "@grafana/data": 8.5.0-pre
- "@grafana/e2e-selectors": 8.5.0-pre
+ "@grafana/data": 9.0.0-pre
+ "@grafana/e2e-selectors": 9.0.0-pre
"@grafana/tsconfig": ^1.2.0-rc1
- "@grafana/ui": 8.5.0-pre
+ "@grafana/ui": 9.0.0-pre
"@rollup/plugin-commonjs": 21.0.2
"@rollup/plugin-node-resolve": 13.1.3
"@sentry/browser": 6.19.1
- "@testing-library/dom": 8.12.0
+ "@testing-library/dom": 8.13.0
"@testing-library/react": 12.1.4
"@testing-library/user-event": ^13.5.0
"@types/angular": 1.8.4
@@ -4203,7 +4203,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@grafana/schema@8.5.0-pre, @grafana/schema@workspace:*, @grafana/schema@workspace:packages/grafana-schema":
+"@grafana/schema@9.0.0-pre, @grafana/schema@workspace:*, @grafana/schema@workspace:packages/grafana-schema":
version: 0.0.0-use.local
resolution: "@grafana/schema@workspace:packages/grafana-schema"
dependencies:
@@ -4250,16 +4250,16 @@ __metadata:
languageName: node
linkType: hard
-"@grafana/toolkit@8.5.0-pre, @grafana/toolkit@workspace:*, @grafana/toolkit@workspace:packages/grafana-toolkit":
+"@grafana/toolkit@9.0.0-pre, @grafana/toolkit@workspace:*, @grafana/toolkit@workspace:packages/grafana-toolkit":
version: 0.0.0-use.local
resolution: "@grafana/toolkit@workspace:packages/grafana-toolkit"
dependencies:
"@babel/core": 7.13.14
"@babel/preset-env": 7.13.12
- "@grafana/data": 8.5.0-pre
+ "@grafana/data": 9.0.0-pre
"@grafana/eslint-config": 2.5.2
"@grafana/tsconfig": ^1.2.0-rc1
- "@grafana/ui": 8.5.0-pre
+ "@grafana/ui": 9.0.0-pre
"@jest/core": 26.6.3
"@rushstack/eslint-patch": 1.0.6
"@types/command-exists": ^1.2.0
@@ -4343,7 +4343,7 @@ __metadata:
languageName: node
linkType: hard
-"@grafana/ui@8.5.0-pre, @grafana/ui@workspace:*, @grafana/ui@workspace:packages/grafana-ui":
+"@grafana/ui@9.0.0-pre, @grafana/ui@workspace:*, @grafana/ui@workspace:packages/grafana-ui":
version: 0.0.0-use.local
resolution: "@grafana/ui@workspace:packages/grafana-ui"
dependencies:
@@ -4351,9 +4351,9 @@ __metadata:
"@emotion/css": 11.7.1
"@emotion/react": 11.8.2
"@grafana/aws-sdk": 0.0.35
- "@grafana/data": 8.5.0-pre
- "@grafana/e2e-selectors": 8.5.0-pre
- "@grafana/schema": 8.5.0-pre
+ "@grafana/data": 9.0.0-pre
+ "@grafana/e2e-selectors": 9.0.0-pre
+ "@grafana/schema": 9.0.0-pre
"@grafana/slate-react": 0.22.10-grafana
"@grafana/tsconfig": ^1.2.0-rc1
"@mdx-js/react": 1.6.22
@@ -4384,7 +4384,7 @@ __metadata:
"@storybook/react": 6.4.15
"@storybook/theming": 6.4.15
"@swc/helpers": 0.3.8
- "@testing-library/dom": 8.12.0
+ "@testing-library/dom": 8.13.0
"@testing-library/jest-dom": 5.16.2
"@testing-library/react": 12.1.4
"@testing-library/react-hooks": 7.0.2
@@ -4490,7 +4490,7 @@ __metadata:
typescript: 4.4.4
uplot: 1.6.19
uuid: 8.3.2
- webpack: 5.70.0
+ webpack: 5.72.0
webpack-filter-warnings-plugin: 1.2.1
languageName: unknown
linkType: soft
@@ -4589,9 +4589,9 @@ __metadata:
resolution: "@jaegertracing/jaeger-ui-components@workspace:packages/jaeger-ui-components"
dependencies:
"@emotion/css": 11.7.1
- "@grafana/data": 8.5.0-pre
+ "@grafana/data": 9.0.0-pre
"@grafana/tsconfig": ^1.2.0-rc1
- "@grafana/ui": 8.5.0-pre
+ "@grafana/ui": 9.0.0-pre
"@types/classnames": ^2.2.7
"@types/deep-freeze": ^0.1.1
"@types/grafana__slate-react": "npm:@types/slate-react@0.22.5"
@@ -7219,9 +7219,9 @@ __metadata:
languageName: node
linkType: hard
-"@reduxjs/toolkit@npm:1.7.2":
- version: 1.7.2
- resolution: "@reduxjs/toolkit@npm:1.7.2"
+"@reduxjs/toolkit@npm:1.8.0":
+ version: 1.8.0
+ resolution: "@reduxjs/toolkit@npm:1.8.0"
dependencies:
immer: ^9.0.7
redux: ^4.1.2
@@ -7235,7 +7235,7 @@ __metadata:
optional: true
react-redux:
optional: true
- checksum: 41c17c660f7c95d776658b9a101f9803c37589b2c08861e90283abe5fab70f99c37af064fae0c5b7501f67308946fa072504f6a801e272f157b9f7427a5e571e
+ checksum: e229571b80d7f5c4407af2050ac2a26a22472541fef1705d5e8b73b93bfb2884072d79e1cea2cd81d89939cc6b6073ef3ddf1680950aac66ba34cd202a6cf371
languageName: node
linkType: hard
@@ -9032,9 +9032,9 @@ __metadata:
languageName: node
linkType: hard
-"@testing-library/dom@npm:8.12.0":
- version: 8.12.0
- resolution: "@testing-library/dom@npm:8.12.0"
+"@testing-library/dom@npm:8.13.0":
+ version: 8.13.0
+ resolution: "@testing-library/dom@npm:8.13.0"
dependencies:
"@babel/code-frame": ^7.10.4
"@babel/runtime": ^7.12.5
@@ -9044,7 +9044,7 @@ __metadata:
dom-accessibility-api: ^0.5.9
lz-string: ^1.4.4
pretty-format: ^27.0.2
- checksum: 2bbf5fa5c1e883571c440ccee76c0568fa5153b43c097456dd7146797256687352bfca9db574e0e78a022ce14722a6acaaba5f680ee16b95e12405501713d34d
+ checksum: 880f1872b9949800d4444e3bdbd03df86d6f41ec7c27136dff1da29e87d2df2d7ee904afcdf895ffce351c25bd12119117eae023354d50e707ad56d43b2ed3ed
languageName: node
linkType: hard
@@ -20454,14 +20454,14 @@ __metadata:
"@react-types/menu": 3.5.2
"@react-types/overlays": 3.5.4
"@react-types/shared": 3.11.2
- "@reduxjs/toolkit": 1.7.2
+ "@reduxjs/toolkit": 1.8.0
"@rtsao/plugin-proposal-class-properties": 7.0.1-patch.1
"@sentry/browser": 6.19.1
"@sentry/types": 6.19.1
"@sentry/utils": 6.19.1
"@swc/core": 1.2.160
"@swc/helpers": 0.3.8
- "@testing-library/dom": 8.12.0
+ "@testing-library/dom": 8.13.0
"@testing-library/jest-dom": 5.16.2
"@testing-library/react": 12.1.4
"@testing-library/react-hooks": 7.0.2
@@ -20589,7 +20589,7 @@ __metadata:
jest-canvas-mock: 2.3.1
jest-date-mock: 1.0.8
jest-fail-on-console: 2.3.0
- jest-junit: 13.0.0
+ jest-junit: 13.1.0
jest-matcher-utils: 27.5.1
jest-mock-console: 1.2.3
jquery: 3.6.0
@@ -20695,7 +20695,7 @@ __metadata:
vendor: "link:./public/vendor"
visjs-network: 4.25.0
wait-on: 6.0.1
- webpack: 5.70.0
+ webpack: 5.72.0
webpack-bundle-analyzer: 4.5.0
webpack-cli: 4.9.2
webpack-dev-server: 4.7.4
@@ -23417,7 +23417,19 @@ __metadata:
languageName: node
linkType: hard
-"jest-junit@npm:13.0.0, jest-junit@npm:^13.0.0":
+"jest-junit@npm:13.1.0":
+ version: 13.1.0
+ resolution: "jest-junit@npm:13.1.0"
+ dependencies:
+ mkdirp: ^1.0.4
+ strip-ansi: ^6.0.1
+ uuid: ^8.3.2
+ xml: ^1.0.1
+ checksum: a12ec522012cf9d0e97d4c665d8ca05b97dc3d25ef3cd0dbba2b095da4b68e1cabac9751a2718a9593b55c620a2b980e0b6c7dd7d33dfc4b73193bc67b9c9ae0
+ languageName: node
+ linkType: hard
+
+"jest-junit@npm:^13.0.0":
version: 13.0.0
resolution: "jest-junit@npm:13.0.0"
dependencies:
@@ -36984,7 +36996,44 @@ __metadata:
languageName: node
linkType: hard
-"webpack@npm:5.70.0, webpack@npm:^5.9.0":
+"webpack@npm:5.72.0":
+ version: 5.72.0
+ resolution: "webpack@npm:5.72.0"
+ dependencies:
+ "@types/eslint-scope": ^3.7.3
+ "@types/estree": ^0.0.51
+ "@webassemblyjs/ast": 1.11.1
+ "@webassemblyjs/wasm-edit": 1.11.1
+ "@webassemblyjs/wasm-parser": 1.11.1
+ acorn: ^8.4.1
+ acorn-import-assertions: ^1.7.6
+ browserslist: ^4.14.5
+ chrome-trace-event: ^1.0.2
+ enhanced-resolve: ^5.9.2
+ es-module-lexer: ^0.9.0
+ eslint-scope: 5.1.1
+ events: ^3.2.0
+ glob-to-regexp: ^0.4.1
+ graceful-fs: ^4.2.9
+ json-parse-better-errors: ^1.0.2
+ loader-runner: ^4.2.0
+ mime-types: ^2.1.27
+ neo-async: ^2.6.2
+ schema-utils: ^3.1.0
+ tapable: ^2.1.1
+ terser-webpack-plugin: ^5.1.3
+ watchpack: ^2.3.1
+ webpack-sources: ^3.2.3
+ peerDependenciesMeta:
+ webpack-cli:
+ optional: true
+ bin:
+ webpack: bin/webpack.js
+ checksum: 8365f1466d0f7adbf80ebc9b780f263a28eeeabcd5fb515249bfd9a56ab7fe8d29ea53df3d9364d0732ab39ae774445eb28abce694ed375b13882a6b2fe93ffc
+ languageName: node
+ linkType: hard
+
+"webpack@npm:^5.9.0":
version: 5.70.0
resolution: "webpack@npm:5.70.0"
dependencies: