Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
164 files changed
+2944
-1508
No files matched your search
@@ -3315,11 +3315,6 @@ exports[`better eslint`] = {
|
||||
"public/app/features/logs/components/InfiniteScroll.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/logs/components/LogDetails.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/app/features/logs/components/LogLabelStats.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
|
||||
+51
-51
@@ -117,14 +117,14 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- yarn storybook --quiet
|
||||
depends_on:
|
||||
- yarn-install
|
||||
detach: true
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: start-storybook
|
||||
- commands:
|
||||
- npx wait-on@7.2.0 -t 1m http://$HOST:$PORT
|
||||
@@ -175,14 +175,14 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- apk add --update git bash
|
||||
- yarn betterer ci
|
||||
depends_on:
|
||||
- yarn-install
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: betterer-frontend
|
||||
- commands:
|
||||
- echo $(/usr/bin/github-app-external-token) > /github-app/token
|
||||
@@ -229,7 +229,7 @@ steps:
|
||||
- yarn-install
|
||||
environment:
|
||||
TEST_MAX_WORKERS: 50%
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: test-frontend
|
||||
trigger:
|
||||
event:
|
||||
@@ -314,7 +314,7 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- yarn run prettier:check
|
||||
@@ -324,7 +324,7 @@ steps:
|
||||
- yarn-install
|
||||
environment:
|
||||
TEST_MAX_WORKERS: 50%
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: lint-frontend
|
||||
- commands:
|
||||
- |-
|
||||
@@ -337,7 +337,7 @@ steps:
|
||||
\ "
|
||||
depends_on:
|
||||
- yarn-install
|
||||
image: node:20-bookworm
|
||||
image: node:22-bookworm
|
||||
name: verify-i18n
|
||||
trigger:
|
||||
event:
|
||||
@@ -667,7 +667,7 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- apk add --update jq bash
|
||||
@@ -678,7 +678,7 @@ steps:
|
||||
- yarn-install
|
||||
environment:
|
||||
NODE_OPTIONS: --max_old_space_size=8192
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: build-frontend-packages
|
||||
- failure: ignore
|
||||
image: grafana/drone-downstream
|
||||
@@ -742,7 +742,7 @@ steps:
|
||||
- yarn-install
|
||||
environment:
|
||||
NODE_OPTIONS: --max_old_space_size=8192
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: build-test-plugins
|
||||
- commands:
|
||||
- apk add --update tar bash
|
||||
@@ -870,7 +870,7 @@ steps:
|
||||
HOST: grafana-server
|
||||
PORT: "3001"
|
||||
PROV_DIR: /grafana/scripts/grafana-server/tmp/conf/provisioning
|
||||
image: node:20-bookworm
|
||||
image: node:22-bookworm
|
||||
name: playwright-plugin-e2e
|
||||
- commands:
|
||||
- apt-get update
|
||||
@@ -960,7 +960,7 @@ steps:
|
||||
- build-frontend-packages
|
||||
environment:
|
||||
NODE_OPTIONS: --max_old_space_size=4096
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: build-storybook
|
||||
when:
|
||||
paths:
|
||||
@@ -1307,7 +1307,7 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- pip3 install codespell
|
||||
@@ -1320,7 +1320,7 @@ steps:
|
||||
- yarn-install
|
||||
environment:
|
||||
NODE_OPTIONS: --max_old_space_size=8192
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: lint-docs
|
||||
- commands:
|
||||
- mkdir -p /hugo/content/docs/grafana/latest
|
||||
@@ -1695,7 +1695,7 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- pip3 install codespell
|
||||
@@ -1708,7 +1708,7 @@ steps:
|
||||
- yarn-install
|
||||
environment:
|
||||
NODE_OPTIONS: --max_old_space_size=8192
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: lint-docs
|
||||
- commands:
|
||||
- mkdir -p /hugo/content/docs/grafana/latest
|
||||
@@ -1771,14 +1771,14 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- apk add --update git bash
|
||||
- yarn betterer ci
|
||||
depends_on:
|
||||
- yarn-install
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: betterer-frontend
|
||||
- commands:
|
||||
- yarn run ci:test-frontend
|
||||
@@ -1786,7 +1786,7 @@ steps:
|
||||
- yarn-install
|
||||
environment:
|
||||
TEST_MAX_WORKERS: 50%
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: test-frontend
|
||||
trigger:
|
||||
branch: main
|
||||
@@ -1829,7 +1829,7 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- yarn run prettier:check
|
||||
@@ -1839,7 +1839,7 @@ steps:
|
||||
- yarn-install
|
||||
environment:
|
||||
TEST_MAX_WORKERS: 50%
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: lint-frontend
|
||||
- commands:
|
||||
- |-
|
||||
@@ -1852,7 +1852,7 @@ steps:
|
||||
\ "
|
||||
depends_on:
|
||||
- yarn-install
|
||||
image: node:20-bookworm
|
||||
image: node:22-bookworm
|
||||
name: verify-i18n
|
||||
trigger:
|
||||
branch: main
|
||||
@@ -2041,14 +2041,14 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- yarn storybook --quiet
|
||||
depends_on:
|
||||
- yarn-install
|
||||
detach: true
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: start-storybook
|
||||
- commands:
|
||||
- npx wait-on@7.2.0 -t 1m http://$HOST:$PORT
|
||||
@@ -2148,7 +2148,7 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- apk add --update jq
|
||||
@@ -2159,7 +2159,7 @@ steps:
|
||||
- yarn install --mode=update-lockfile
|
||||
depends_on:
|
||||
- yarn-install
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: update-package-json-version
|
||||
- commands:
|
||||
- apk add --update jq bash
|
||||
@@ -2171,7 +2171,7 @@ steps:
|
||||
- update-package-json-version
|
||||
environment:
|
||||
NODE_OPTIONS: --max_old_space_size=8192
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: build-frontend-packages
|
||||
- commands:
|
||||
- docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
@@ -2226,7 +2226,7 @@ steps:
|
||||
- yarn-install
|
||||
environment:
|
||||
NODE_OPTIONS: --max_old_space_size=8192
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: build-test-plugins
|
||||
- commands:
|
||||
- apk add --update tar bash
|
||||
@@ -2354,7 +2354,7 @@ steps:
|
||||
HOST: grafana-server
|
||||
PORT: "3001"
|
||||
PROV_DIR: /grafana/scripts/grafana-server/tmp/conf/provisioning
|
||||
image: node:20-bookworm
|
||||
image: node:22-bookworm
|
||||
name: playwright-plugin-e2e
|
||||
- commands:
|
||||
- apt-get update
|
||||
@@ -2444,7 +2444,7 @@ steps:
|
||||
- build-frontend-packages
|
||||
environment:
|
||||
NODE_OPTIONS: --max_old_space_size=4096
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: build-storybook
|
||||
when:
|
||||
paths:
|
||||
@@ -2494,7 +2494,7 @@ steps:
|
||||
GRAFANA_MISC_STATS_API_KEY:
|
||||
from_secret: grafana_misc_stats_api_key
|
||||
failure: ignore
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: publish-frontend-metrics
|
||||
when:
|
||||
repo:
|
||||
@@ -2536,7 +2536,7 @@ steps:
|
||||
environment:
|
||||
NPM_TOKEN:
|
||||
from_secret: npm_token
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: release-canary-npm-packages
|
||||
when:
|
||||
paths:
|
||||
@@ -2943,14 +2943,14 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- apk add --update git bash
|
||||
- yarn betterer ci
|
||||
depends_on:
|
||||
- yarn-install
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: betterer-frontend
|
||||
- commands:
|
||||
- yarn run ci:test-frontend
|
||||
@@ -2958,7 +2958,7 @@ steps:
|
||||
- yarn-install
|
||||
environment:
|
||||
TEST_MAX_WORKERS: 50%
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: test-frontend
|
||||
trigger:
|
||||
branch:
|
||||
@@ -2999,7 +2999,7 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- yarn run prettier:check
|
||||
@@ -3009,7 +3009,7 @@ steps:
|
||||
- yarn-install
|
||||
environment:
|
||||
TEST_MAX_WORKERS: 50%
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: lint-frontend
|
||||
- commands:
|
||||
- |-
|
||||
@@ -3022,7 +3022,7 @@ steps:
|
||||
\ "
|
||||
depends_on:
|
||||
- yarn-install
|
||||
image: node:20-bookworm
|
||||
image: node:22-bookworm
|
||||
name: verify-i18n
|
||||
trigger:
|
||||
branch:
|
||||
@@ -3863,7 +3863,7 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- ./bin/build artifacts npm retrieve --tag ${DRONE_TAG}
|
||||
@@ -3887,7 +3887,7 @@ steps:
|
||||
NPM_TOKEN:
|
||||
from_secret: npm_token
|
||||
failure: ignore
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: release-npm-packages
|
||||
trigger:
|
||||
event:
|
||||
@@ -3922,7 +3922,7 @@ steps:
|
||||
1\n else\n sleep 60\n fi\n done\n
|
||||
\ "
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: verify-grafanacom
|
||||
trigger:
|
||||
event:
|
||||
@@ -4022,7 +4022,7 @@ steps:
|
||||
\ "
|
||||
depends_on:
|
||||
- publish-grafanacom
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: verify-grafanacom
|
||||
trigger:
|
||||
event:
|
||||
@@ -4446,14 +4446,14 @@ steps:
|
||||
- commands:
|
||||
- yarn install --immutable || yarn install --immutable
|
||||
depends_on: []
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: yarn-install
|
||||
- commands:
|
||||
- apk add --update git bash
|
||||
- yarn betterer ci
|
||||
depends_on:
|
||||
- yarn-install
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: betterer-frontend
|
||||
- commands:
|
||||
- yarn run ci:test-frontend
|
||||
@@ -4461,7 +4461,7 @@ steps:
|
||||
- yarn-install
|
||||
environment:
|
||||
TEST_MAX_WORKERS: 50%
|
||||
image: node:20.9.0-alpine
|
||||
image: node:22.11.0-alpine
|
||||
name: test-frontend
|
||||
trigger:
|
||||
cron:
|
||||
@@ -5443,8 +5443,8 @@ steps:
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM docker:27-cli
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM alpine/git:2.40.1
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM golang:1.23.1-alpine
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM node:20.9.0-alpine
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM node:20-bookworm
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM node:22.11.0-alpine
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM node:22-bookworm
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM google/cloud-sdk:431.0.0
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM grafana/grafana-ci-deploy:1.3.3
|
||||
- trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM alpine:3.20.3
|
||||
@@ -5482,8 +5482,8 @@ steps:
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL docker:27-cli
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL alpine/git:2.40.1
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL golang:1.23.1-alpine
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL node:20.9.0-alpine
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL node:20-bookworm
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL node:22.11.0-alpine
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL node:22-bookworm
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL google/cloud-sdk:431.0.0
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL grafana/grafana-ci-deploy:1.3.3
|
||||
- trivy --exit-code 1 --severity HIGH,CRITICAL alpine:3.20.3
|
||||
@@ -5751,6 +5751,6 @@ kind: secret
|
||||
name: gcr_credentials
|
||||
---
|
||||
kind: signature
|
||||
hmac: 04ba0c9b8e69705a28a24ba03de14ece0b15c4b44f6262fcbc6a9ee874b5a9db
|
||||
hmac: 08edb24b8ec53e0ce09f525cf6fdf4ea5dc67710fe64c3ea93ceaf3af42b8305
|
||||
|
||||
...
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
path: './pr'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.9.0
|
||||
node-version: 22.11.0
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.9.0
|
||||
node-version: 22.11.0
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.9.0
|
||||
node-version: 22.11.0
|
||||
|
||||
- name: Get built packages from pr
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
|
||||
ARG BASE_IMAGE=alpine:3.20
|
||||
ARG JS_IMAGE=node:20-alpine
|
||||
ARG JS_IMAGE=node:22-alpine
|
||||
ARG JS_PLATFORM=linux/amd64
|
||||
ARG GO_IMAGE=golang:1.23.1-alpine
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ We recommend using [Homebrew](https://brew.sh/) for installing any missing depen
|
||||
```
|
||||
brew install git
|
||||
brew install go
|
||||
brew install node@20
|
||||
brew install node@22
|
||||
brew install corepack
|
||||
corepack enable
|
||||
```
|
||||
|
||||
@@ -71,7 +71,7 @@ You must have an authorized viewer permission to see an image rendered by a dire
|
||||
The same permission is also required to view embedded links unless you have anonymous access permission enabled for your Grafana instance.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Anonymous access permission is not available in Grafana Cloud.
|
||||
Anonymous access permission is not available in Grafana Cloud. This feature is only supported for Grafana Enterprise and Grafana Open Source.
|
||||
{{< /admonition >}}
|
||||
|
||||
## Share dashboards {#share-a-dashboard}
|
||||
@@ -267,10 +267,10 @@ https://play.grafana.org/render/d-solo/ktMs4D6Mk?from=2024-09-03T11:55:44.442Z&t
|
||||
|
||||
### Share an embed
|
||||
|
||||
You can share a panel by embedding it on another website using an iframe. Users must be signed into Grafana to view the panel.
|
||||
You can share a panel by embedding it on another website using an iframe. Users must be signed into Grafana to view the panel unless you have anonymous access permission enabled for your Grafana instance.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Anonymous access permission is no longer available for Grafana Cloud.
|
||||
Anonymous access permission is no longer available for Grafana Cloud even for panels in [externally shared dashboards](ref:shared-dashboards). This feature is only supported for Grafana Enterprise and Grafana Open Source.
|
||||
{{< /admonition >}}
|
||||
|
||||
To create a panel that can be embedded, follow these steps:
|
||||
|
||||
@@ -116,6 +116,7 @@ For details, refer to [Configuring using Workload Identity](#configuring-using-w
|
||||
| **Application (client) ID** | Sets the application/client ID for the Azure AD app registration to use for authentication. |
|
||||
| **Client secret** | Sets the application client secret for the Azure AD app registration to use for authentication. For details, see the [Azure application secret docs](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal#option-2-create-a-new-application-secret). |
|
||||
| **Default subscription** | _(Optional)_ Sets a default subscription for template variables to use. |
|
||||
| **Enable Basic Logs** | Allows this data source to execute queries against [Basic Logs tables](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/basic-logs-query?tabs=portal-1) in supported Log Analytics Workspaces. These queries may incur additional costs. |
|
||||
|
||||
### Provision the data source
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ Azure Monitor Logs collects and organises log and performance data from [support
|
||||
While Azure Monitor Metrics stores only simplified numerical data, Logs can store different data types, each with their own structure.
|
||||
You can also perform complex analysis of Logs data by using KQL.
|
||||
|
||||
The Azure Monitor data source also supports querying of [Basic Logs](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/basic-logs-query?tabs=portal-1) tables (if they exist in your Log Analytics workspace). This feature must be enabled in the data source configuration.
|
||||
|
||||
{{< figure src="/static/img/docs/azure-monitor/query-editor-logs.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor Logs sample query comparing successful requests to failed requests" >}}
|
||||
|
||||
### Create a Logs query
|
||||
@@ -146,6 +148,21 @@ You can also perform complex analysis of Logs data by using KQL.
|
||||
|
||||
You can also augment queries by using [template variables]({{< relref "./template-variables" >}}).
|
||||
|
||||
**To create a Basic Logs query:**
|
||||
|
||||
1. Ensure that the data source has the `Enable Basic Logs` toggle enabled.
|
||||
1. In a Grafana panel, select the **Azure Monitor** data source.
|
||||
1. Select the **Logs** service.
|
||||
1. Select a resource to query. Multiple resources can be selected as long as they are of the same type.
|
||||
1. Switch the `Logs` toggle from `Analytics` to `Basic`. A modal will display to notify users of potential additional costs.
|
||||
{{% admonition type="note" %}}
|
||||
Basic Logs queries do not support time-ranges specified in the query. The time-range will be hardcoded to the dashboard time-range. There are also other query limitations. See the
|
||||
[documentation for details.](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/basic-logs-query?tabs=portal-1#limitations)
|
||||
{{% /admonition %}}
|
||||
1. Enter your KQL query.
|
||||
|
||||
You can also augment queries by using [template variables](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/azure-monitor/template-variables/).
|
||||
|
||||
### Logs query examples
|
||||
|
||||
Azure Monitor Logs queries are written using the Kusto Query Language (KQL), a rich language similar to SQL.
|
||||
|
||||
@@ -135,7 +135,7 @@ On visualizations that support thresholds, Grafana has the following default thr
|
||||
- 80 = red
|
||||
- Base = green
|
||||
- Mode = Absolute
|
||||
- Show thresholds = Off (for some visualizations); for more information, see the [Show thresholds](#show-threshold) option.
|
||||
- Show thresholds = Off (for some visualizations); for more information, see the [Show thresholds](#show-thresholds) option.
|
||||
|
||||
## Thresholds options
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `ssoSettingsLDAP` | Use the new SSO Settings API to configure LDAP |
|
||||
| `useSessionStorageForRedirection` | Use session storage for handling the redirection after login |
|
||||
| `reportingUseRawTimeRange` | Uses the original report or dashboard time range instead of making an absolute transformation |
|
||||
| `elasticsearchCrossClusterSearch` | Enables cross cluster search in the Elasticsearch datasource |
|
||||
|
||||
## Experimental feature toggles
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"devDependencies": {
|
||||
"@grafana/plugin-configs": "workspace:*",
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/node": "20.14.14",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/prismjs": "1.26.4",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.2.25",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"devDependencies": {
|
||||
"@grafana/plugin-configs": "11.5.0-pre",
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/node": "20.14.14",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/prismjs": "1.26.4",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.2.25",
|
||||
|
||||
@@ -84,7 +84,7 @@ require (
|
||||
github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447 // @grafana/sharing-squad
|
||||
github.com/grafana/gomemcache v0.0.0-20240805133030-fdaf6a95408e // @grafana/grafana-operator-experience-squad
|
||||
github.com/grafana/grafana-app-sdk v0.23.1 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/grafana-aws-sdk v0.31.3 // @grafana/aws-datasources
|
||||
github.com/grafana/grafana-aws-sdk v0.31.5 // @grafana/aws-datasources
|
||||
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.2 // @grafana/partner-datasources
|
||||
github.com/grafana/grafana-cloud-migration-snapshot v1.3.0 // @grafana/grafana-operator-experience-squad
|
||||
github.com/grafana/grafana-google-sdk-go v0.1.0 // @grafana/partner-datasources
|
||||
@@ -508,7 +508,7 @@ require (
|
||||
github.com/grafana/grafana-foundation-sdk/go v0.0.0-20241101005901-83e3491f2a70 // indirect
|
||||
github.com/grafana/jsonparser v0.0.0-20240425183733-ea80629e1a32 // indirect
|
||||
github.com/grafana/loki/pkg/push v0.0.0-20231124142027-e52380921608 // indirect
|
||||
github.com/grafana/sqlds/v4 v4.1.0 // indirect
|
||||
github.com/grafana/sqlds/v4 v4.1.3 // indirect
|
||||
github.com/hashicorp/consul/api v1.29.4 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
|
||||
|
||||
@@ -2318,8 +2318,8 @@ github.com/grafana/gomemcache v0.0.0-20240805133030-fdaf6a95408e h1:UlEET0InuoFa
|
||||
github.com/grafana/gomemcache v0.0.0-20240805133030-fdaf6a95408e/go.mod h1:IGRj8oOoxwJbHBYl1+OhS9UjQR0dv6SQOep7HqmtyFU=
|
||||
github.com/grafana/grafana-app-sdk v0.23.1 h1:BRpUG0bA0oVxjthkmO2thuJBo3nbjaRSSmZJHw+mA8I=
|
||||
github.com/grafana/grafana-app-sdk v0.23.1/go.mod h1:KzgPnTJfMeckGmMctv6CJb8Jr/o/5rwARDyjXoeR0Fc=
|
||||
github.com/grafana/grafana-aws-sdk v0.31.3 h1:QlgIwyyozYYQf/dL279Baicyax+SuE99Set5chMnq1s=
|
||||
github.com/grafana/grafana-aws-sdk v0.31.3/go.mod h1:5nt5Gmp6+GyM+Jr7xsXKJtbizxbYXXLmEac6kw5paQI=
|
||||
github.com/grafana/grafana-aws-sdk v0.31.5 h1:4HpMQx7n4Qqoi7Bgu8KHQ2QKT9fYYdHilX/Gh3FZKBE=
|
||||
github.com/grafana/grafana-aws-sdk v0.31.5/go.mod h1:5p4Cjyr5ZiR6/RT2nFWkJ8XpIKgX4lAUmUMu70m2yCM=
|
||||
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.2 h1:fV6IgVtViXcYZ4VqTAMuVBTLuGAnI27HhQkaLttzbPE=
|
||||
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.2/go.mod h1:Cbh94bfL5o6mUSaHFiOkx4r4CRKlo/DJLx4dPL8XrE0=
|
||||
github.com/grafana/grafana-cloud-migration-snapshot v1.3.0 h1:F0O9eTy4jHjEd1Z3/qIza2GdY7PYpTddUeaq9p3NKGU=
|
||||
@@ -2372,8 +2372,8 @@ github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrR
|
||||
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
|
||||
github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56 h1:SDGrP81Vcd102L3UJEryRd1eestRw73wt+b8vnVEFe0=
|
||||
github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56/go.mod h1:S4+611dxnKt8z/ulbvaJzcgSHsuhjVc1QHNTcr1R7Fw=
|
||||
github.com/grafana/sqlds/v4 v4.1.0 h1:dPvqaxmLJYqj5/EgP08Xcyy6RVaDdO8hWHpP4c1FQX4=
|
||||
github.com/grafana/sqlds/v4 v4.1.0/go.mod h1:3Z3r99mgA7+3mzQhABsNZnNtjLx7a0bGOgLVdAsFt2M=
|
||||
github.com/grafana/sqlds/v4 v4.1.3 h1:+Hy5Yz+tSbD5N3yuLM0VKTsWlVaCzM1S1m1QEBZL7fE=
|
||||
github.com/grafana/sqlds/v4 v4.1.3/go.mod h1:Lx8IR939lIrCBpCKthv7AXs7E7bmNWPgt0gene/idT8=
|
||||
github.com/grafana/tempo v1.5.1-0.20241001135150-ed943d7a56b2 h1:XMreZ1SPjLpd9zhql5FXKFYwAcgBzS2E2MOPx4n+FyY=
|
||||
github.com/grafana/tempo v1.5.1-0.20241001135150-ed943d7a56b2/go.mod h1:UKONJhBCxmL+0ri27VMledCVzZIJqnl6Ah24A5vCRzs=
|
||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
||||
|
||||
@@ -1003,7 +1003,12 @@ github.com/grafana/go-json v0.0.0-20241115232854-f14426c40ff2 h1:8xGrYqQ1GM4aaMk
|
||||
github.com/grafana/go-json v0.0.0-20241115232854-f14426c40ff2/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/grafana/gomemcache v0.0.0-20240229205252-cd6a66d6fb56/go.mod h1:PGk3RjYHpxMM8HFPhKKo+vve3DdlPUELZLSDEFehPuU=
|
||||
github.com/grafana/grafana-app-sdk v0.19.0/go.mod h1:y0BgzYxc+a7CwOqkwUhN9zXd5cgZJjd2zAbgHEd/xzo=
|
||||
github.com/grafana/grafana-aws-sdk v0.31.5 h1:4HpMQx7n4Qqoi7Bgu8KHQ2QKT9fYYdHilX/Gh3FZKBE=
|
||||
github.com/grafana/grafana-aws-sdk v0.31.5/go.mod h1:5p4Cjyr5ZiR6/RT2nFWkJ8XpIKgX4lAUmUMu70m2yCM=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.258.0/go.mod h1:jN19FbzhAcPTLPIy31X5nvx38rR5eoD/1rASiip0GBY=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.6/go.mod h1:Tk376Nbldo4Cha9RgiU7ik8WKFkNpfds98aUzS8omLE=
|
||||
github.com/grafana/sqlds/v4 v4.1.3 h1:+Hy5Yz+tSbD5N3yuLM0VKTsWlVaCzM1S1m1QEBZL7fE=
|
||||
github.com/grafana/sqlds/v4 v4.1.3/go.mod h1:Lx8IR939lIrCBpCKthv7AXs7E7bmNWPgt0gene/idT8=
|
||||
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU=
|
||||
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU=
|
||||
github.com/grafana/thema v0.0.0-20230511182720-3146087fcc26 h1:HX927q4X1n451pnGb8U0wq74i8PCzuxVjzv7TyD10kc=
|
||||
@@ -1026,6 +1031,7 @@ github.com/hashicorp/go-hclog v1.2.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVH
|
||||
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.1 h1:xQEY9yB2wnHitoSzk/B9UjXWRQ67QKu5AOm8aFp8N3I=
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.1/go.mod h1:upybraOAblm4S7rx0+jeNy+CWWhzywQsSRV5033mMu4=
|
||||
github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0=
|
||||
github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE=
|
||||
github.com/hashicorp/go.net v0.0.1 h1:sNCoNyDEvN1xa+X0baata4RdcpKwcMS6DH+xwfqPgjw=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
@@ -1699,11 +1705,14 @@ go.opentelemetry.io/contrib/exporters/autoexport v0.53.0 h1:13K+tY7E8GJInkrvRiPA
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.53.0/go.mod h1:lyQF6xQ4iDnMg4sccNdFs1zf62xd79YI8vZqKjOTwMs=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.52.0/go.mod h1:BMsdeOxN04K0L5FNUBfjFdvwWGNe/rkmSwH4Aelu/X0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.53.0/go.mod h1:ImRBLMJv177/pwiLZ7tU7HDGNdBv7rS0HQ99eN/zBl8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.56.0/go.mod h1:3qi2EEwMgB4xnKgPLqsDP3j9qxnHDZeHsnAxfjQqTko=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.23.0 h1:aaIGWc5JdfRGpCafLRxMJbD65MfTa206AwSKkvGS0Hg=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.23.0/go.mod h1:Gyz7V7XghvwTq+mIhLFlTgcc03UDroOg8vezs4NLhwU=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.27.0 h1:IjgxbomVrV9za6bRi8fWCNXENs0co37SZedQilP2hm0=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.27.0/go.mod h1:Dv9obQz25lCisDvvs4dy28UPh974CxkahRDUPsY7y9E=
|
||||
go.opentelemetry.io/contrib/propagators/jaeger v1.31.0/go.mod h1:OpgiBRssaVKOTM5lSKkOBIGQh/ixvfZRmxQXARK/kGQ=
|
||||
go.opentelemetry.io/contrib/samplers/jaegerremote v0.25.0/go.mod h1:KjcLnhrgBMLiPPAqyoGhuNmF6jm38LCPV6hdbBeFOTU=
|
||||
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
|
||||
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
|
||||
go.opentelemetry.io/otel/bridge/opencensus v1.26.0 h1:DZzxj9QjznMVoehskOJnFP2gsTCWtDTFBDvFhPAY7nc=
|
||||
|
||||
+9
-9
@@ -57,7 +57,7 @@
|
||||
"betterer": "betterer --tsconfig ./scripts/cli/tsconfig.json",
|
||||
"betterer:json": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/bettererResultsToJson.ts",
|
||||
"betterer:merge": "betterer merge --tsconfig ./scripts/cli/tsconfig.json",
|
||||
"betterer:stats": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/reportBettererStats.ts",
|
||||
"betterer:stats": "node ./scripts/cli/reportBettererStats.mjs",
|
||||
"betterer:issues": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/generateBettererIssues.ts",
|
||||
"plugin:build": "nx run-many -t build --projects='tag:scope:plugin'",
|
||||
"plugin:build:commit": "nx run-many -t build:commit --projects='tag:scope:plugin'",
|
||||
@@ -125,7 +125,7 @@
|
||||
"@types/lodash": "4.17.13",
|
||||
"@types/logfmt": "^1.2.3",
|
||||
"@types/lucene": "^2",
|
||||
"@types/node": "20.16.15",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/node-forge": "^1",
|
||||
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
@@ -139,7 +139,7 @@
|
||||
"@types/react-router-dom": "5.3.3",
|
||||
"@types/react-table": "7.7.20",
|
||||
"@types/react-test-renderer": "18.3.0",
|
||||
"@types/react-transition-group": "4.4.11",
|
||||
"@types/react-transition-group": "4.4.12",
|
||||
"@types/react-virtualized-auto-sizer": "1.0.4",
|
||||
"@types/react-window": "1.8.8",
|
||||
"@types/react-window-infinite-loader": "^1",
|
||||
@@ -254,7 +254,7 @@
|
||||
"dependencies": {
|
||||
"@bsull/augurs": "^0.7.0",
|
||||
"@emotion/css": "11.13.5",
|
||||
"@emotion/react": "11.13.5",
|
||||
"@emotion/react": "11.14.0",
|
||||
"@fingerprintjs/fingerprintjs": "^3.4.2",
|
||||
"@floating-ui/react": "0.26.28",
|
||||
"@formatjs/intl-durationformat": "^0.6.0",
|
||||
@@ -295,10 +295,10 @@
|
||||
"@opentelemetry/exporter-collector": "0.25.0",
|
||||
"@opentelemetry/semantic-conventions": "1.27.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@react-aria/dialog": "3.5.19",
|
||||
"@react-aria/focus": "3.18.4",
|
||||
"@react-aria/overlays": "3.23.4",
|
||||
"@react-aria/utils": "3.25.3",
|
||||
"@react-aria/dialog": "3.5.20",
|
||||
"@react-aria/focus": "3.19.0",
|
||||
"@react-aria/overlays": "3.24.0",
|
||||
"@react-aria/utils": "3.26.0",
|
||||
"@react-awesome-query-builder/ui": "6.6.4",
|
||||
"@reduxjs/toolkit": "2.3.0",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
@@ -448,7 +448,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
"node": ">= 22"
|
||||
},
|
||||
"packageManager": "yarn@4.5.3",
|
||||
"dependenciesMeta": {
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"@types/dompurify": "^3.0.0",
|
||||
"@types/history": "4.7.11",
|
||||
"@types/lodash": "4.17.13",
|
||||
"@types/node": "20.16.15",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/papaparse": "5.3.15",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.2.25",
|
||||
|
||||
@@ -242,4 +242,5 @@ export interface FeatureToggles {
|
||||
azureMonitorEnableUserAuth?: boolean;
|
||||
alertingNotificationsStepMode?: boolean;
|
||||
feedbackButton?: boolean;
|
||||
elasticsearchCrossClusterSearch?: boolean;
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "15.3.0",
|
||||
"@types/node": "20.16.15",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/semver": "7.5.8",
|
||||
"esbuild": "0.24.0",
|
||||
"rimraf": "6.0.1",
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.26.0",
|
||||
"@babel/preset-env": "7.26.0",
|
||||
"@babel/preset-react": "7.25.9",
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"@grafana/tsconfig": "^2.0.0",
|
||||
"@rollup/plugin-node-resolve": "15.3.0",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
@@ -68,7 +68,7 @@
|
||||
"@types/d3": "^7",
|
||||
"@types/jest": "^29.5.4",
|
||||
"@types/lodash": "4.17.13",
|
||||
"@types/node": "20.16.15",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-virtualized-auto-sizer": "1.0.4",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"@svgr/plugin-prettier": "^8.1.0",
|
||||
"@svgr/plugin-svgo": "^8.1.0",
|
||||
"@types/babel__core": "^7",
|
||||
"@types/node": "20.16.15",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.2.25",
|
||||
"esbuild": "0.24.0",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@testing-library/react": "16.0.1",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "^29.5.4",
|
||||
"@types/node": "20.16.15",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/systemjs": "6.15.1",
|
||||
"jest": "^29.6.4",
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/jquery": "3.5.32",
|
||||
"@types/lodash": "4.17.13",
|
||||
"@types/node": "20.16.15",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/prismjs": "1.26.5",
|
||||
"@types/react": "18.3.3",
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "^29.5.4",
|
||||
"@types/lodash": "4.17.13",
|
||||
"@types/node": "20.16.15",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.2.25",
|
||||
"@types/react-virtualized-auto-sizer": "1.0.4",
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.13.5",
|
||||
"@emotion/react": "11.13.5",
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/serialize": "1.3.3",
|
||||
"@floating-ui/react": "0.26.28",
|
||||
"@grafana/data": "11.5.0-pre",
|
||||
@@ -58,10 +58,10 @@
|
||||
"@leeoniya/ufuzzy": "1.0.17",
|
||||
"@monaco-editor/react": "4.6.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@react-aria/dialog": "3.5.19",
|
||||
"@react-aria/focus": "3.18.4",
|
||||
"@react-aria/overlays": "3.23.4",
|
||||
"@react-aria/utils": "3.25.3",
|
||||
"@react-aria/dialog": "3.5.20",
|
||||
"@react-aria/focus": "3.19.0",
|
||||
"@react-aria/overlays": "3.24.0",
|
||||
"@react-aria/utils": "3.26.0",
|
||||
"@tanstack/react-virtual": "^3.5.1",
|
||||
"@types/jquery": "3.5.32",
|
||||
"@types/lodash": "4.17.13",
|
||||
@@ -144,14 +144,14 @@
|
||||
"@types/is-hotkey": "0.1.10",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/mock-raf": "1.0.6",
|
||||
"@types/node": "20.16.15",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/prismjs": "1.26.5",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-color": "3.0.12",
|
||||
"@types/react-color": "3.0.13",
|
||||
"@types/react-dom": "18.2.25",
|
||||
"@types/react-highlight-words": "0.20.0",
|
||||
"@types/react-test-renderer": "18.3.0",
|
||||
"@types/react-transition-group": "4.4.11",
|
||||
"@types/react-transition-group": "4.4.12",
|
||||
"@types/react-window": "1.8.8",
|
||||
"@types/slate": "0.47.11",
|
||||
"@types/slate-plain-serializer": "0.7.5",
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
@@ -149,16 +148,13 @@ type ServerLockService interface {
|
||||
func SetDualWritingMode(
|
||||
ctx context.Context,
|
||||
kvs NamespacedKVStore,
|
||||
legacy LegacyStorage,
|
||||
storage Storage,
|
||||
entity string,
|
||||
desiredMode DualWriterMode,
|
||||
reg prometheus.Registerer,
|
||||
serverLockService ServerLockService,
|
||||
requestInfo *request.RequestInfo,
|
||||
cfg *SyncerConfig,
|
||||
) (DualWriterMode, error) {
|
||||
if cfg == nil {
|
||||
return Mode0, errors.New("syncer config is nil")
|
||||
}
|
||||
// Mode0 means no DualWriter
|
||||
if desiredMode == Mode0 {
|
||||
if cfg.Mode == Mode0 {
|
||||
return Mode0, nil
|
||||
}
|
||||
|
||||
@@ -174,58 +170,66 @@ func SetDualWritingMode(
|
||||
errDualWriterSetCurrentMode := errors.New("failed to set current dual writing mode")
|
||||
|
||||
// Use entity name as key
|
||||
m, ok, err := kvs.Get(ctx, entity)
|
||||
m, ok, err := kvs.Get(ctx, cfg.Kind)
|
||||
if err != nil {
|
||||
return Mode0, errors.New("failed to fetch current dual writing mode")
|
||||
}
|
||||
|
||||
currentMode, valid := toMode[m]
|
||||
currentMode, exists := toMode[m]
|
||||
|
||||
if !valid && ok {
|
||||
// If the mode does not exist in our mapping, we log an error.
|
||||
if !exists && ok {
|
||||
// Only log if "ok" because initially all instances will have mode unset for playlists.
|
||||
klog.Infof("invalid dual writing mode for %s mode: %v", entity, m)
|
||||
klog.Infof("invalid dual writing mode for %s mode: %v", cfg.Kind, m)
|
||||
}
|
||||
|
||||
if !valid || !ok {
|
||||
// If the mode does not exist in our mapping, and we also didn't find an entry for this kind, fallback.
|
||||
if !exists || !ok {
|
||||
// Default to mode 1
|
||||
currentMode = Mode1
|
||||
|
||||
err := kvs.Set(ctx, entity, fmt.Sprint(currentMode))
|
||||
if err != nil {
|
||||
if err := kvs.Set(ctx, cfg.Kind, fmt.Sprint(currentMode)); err != nil {
|
||||
return Mode0, errDualWriterSetCurrentMode
|
||||
}
|
||||
}
|
||||
|
||||
// Handle transitions to the desired mode.
|
||||
switch {
|
||||
case desiredMode == Mode2 || desiredMode == Mode1:
|
||||
currentMode = desiredMode
|
||||
err := kvs.Set(ctx, entity, fmt.Sprint(currentMode))
|
||||
if err != nil {
|
||||
case cfg.Mode == Mode2 || cfg.Mode == Mode1:
|
||||
// Directly set the mode for Mode1 and Mode2.
|
||||
currentMode = cfg.Mode
|
||||
if err := kvs.Set(ctx, cfg.Kind, fmt.Sprint(currentMode)); err != nil {
|
||||
return Mode0, errDualWriterSetCurrentMode
|
||||
}
|
||||
case desiredMode >= Mode3 && currentMode < Mode3:
|
||||
syncOk, err := runDataSyncer(ctx, currentMode, legacy, storage, entity, reg, serverLockService, requestInfo)
|
||||
case cfg.Mode >= Mode3 && currentMode < Mode3:
|
||||
// Transitioning to Mode3 or higher requires data synchronization.
|
||||
cfgModeTmp := cfg.Mode
|
||||
// Before running the sync, set the syncer config to the current mode, as we have to run the syncer
|
||||
// once in the current active mode before we can upgrade.
|
||||
cfg.Mode = currentMode
|
||||
syncOk, err := runDataSyncer(ctx, cfg)
|
||||
// Once we are done with running the syncer, we can change the mode back on the config to the desired one.
|
||||
cfg.Mode = cfgModeTmp
|
||||
if err != nil {
|
||||
klog.Info("data syncer failed for mode:", m)
|
||||
return currentMode, err
|
||||
return Mode0, err
|
||||
}
|
||||
if !syncOk {
|
||||
klog.Info("data syncer not ok for mode:", m)
|
||||
return currentMode, nil
|
||||
}
|
||||
|
||||
err = kvs.Set(ctx, entity, fmt.Sprint(desiredMode))
|
||||
if err != nil {
|
||||
return currentMode, errDualWriterSetCurrentMode
|
||||
// If sync is successful, update the mode to the desired one.
|
||||
if err := kvs.Set(ctx, cfg.Kind, fmt.Sprint(cfg.Mode)); err != nil {
|
||||
return Mode0, errDualWriterSetCurrentMode
|
||||
}
|
||||
return desiredMode, nil
|
||||
case desiredMode >= Mode3 && currentMode >= Mode3:
|
||||
currentMode = desiredMode
|
||||
err := kvs.Set(ctx, entity, fmt.Sprint(currentMode))
|
||||
if err != nil {
|
||||
return currentMode, errDualWriterSetCurrentMode
|
||||
return cfg.Mode, nil
|
||||
case cfg.Mode >= Mode3 && currentMode >= Mode3:
|
||||
// If already in Mode3 or higher, simply update to the desired mode.
|
||||
currentMode = cfg.Mode
|
||||
if err := kvs.Set(ctx, cfg.Kind, fmt.Sprint(currentMode)); err != nil {
|
||||
return Mode0, errDualWriterSetCurrentMode
|
||||
}
|
||||
default:
|
||||
// Handle any unexpected cases (should not normally happen).
|
||||
return Mode0, errDualWriterSetCurrentMode
|
||||
}
|
||||
return currentMode, nil
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"k8s.io/klog/v2"
|
||||
|
||||
"github.com/grafana/authlib/claims"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
)
|
||||
@@ -29,12 +30,60 @@ type syncItem struct {
|
||||
accessorLegacy utils.GrafanaMetaAccessor
|
||||
}
|
||||
|
||||
const dataSyncerInterval = 60 * time.Minute
|
||||
type SyncerConfig struct {
|
||||
Kind string
|
||||
RequestInfo *request.RequestInfo
|
||||
|
||||
// StartPeriodicDataSyncer starts a background job that will execute the DataSyncer every 60 minutes
|
||||
func StartPeriodicDataSyncer(ctx context.Context, mode DualWriterMode, legacy LegacyStorage, storage Storage,
|
||||
kind string, reg prometheus.Registerer, serverLockService ServerLockService, requestInfo *request.RequestInfo) {
|
||||
log := klog.NewKlogr().WithName("legacyToUnifiedStorageDataSyncer").WithValues("mode", mode, "resource", kind)
|
||||
Mode DualWriterMode
|
||||
LegacyStorage LegacyStorage
|
||||
Storage Storage
|
||||
ServerLockService ServerLockService
|
||||
|
||||
DataSyncerInterval time.Duration
|
||||
DataSyncerRecordsLimit int
|
||||
|
||||
Reg prometheus.Registerer
|
||||
}
|
||||
|
||||
func (s *SyncerConfig) Validate() error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("syncer config is nil")
|
||||
}
|
||||
if s.Kind == "" {
|
||||
return fmt.Errorf("kind must be specified")
|
||||
}
|
||||
if s.RequestInfo == nil {
|
||||
return fmt.Errorf("requestInfo must be specified")
|
||||
}
|
||||
if s.ServerLockService == nil {
|
||||
return fmt.Errorf("serverLockService must be specified")
|
||||
}
|
||||
if s.Storage == nil {
|
||||
return fmt.Errorf("storage must be specified")
|
||||
}
|
||||
if s.LegacyStorage == nil {
|
||||
return fmt.Errorf("legacy storage must be specified")
|
||||
}
|
||||
if s.DataSyncerInterval == 0 {
|
||||
s.DataSyncerInterval = time.Hour
|
||||
}
|
||||
if s.DataSyncerRecordsLimit == 0 {
|
||||
s.DataSyncerRecordsLimit = 1000
|
||||
}
|
||||
if s.Reg == nil {
|
||||
s.Reg = prometheus.DefaultRegisterer
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartPeriodicDataSyncer starts a background job that will execute the DataSyncer, syncing the data
|
||||
// from the hosted grafana backend into the unified storage backend. This is run in the grafana instance.
|
||||
func StartPeriodicDataSyncer(ctx context.Context, cfg *SyncerConfig) error {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid syncer config: %w", err)
|
||||
}
|
||||
|
||||
log := klog.NewKlogr().WithName("legacyToUnifiedStorageDataSyncer").WithValues("mode", cfg.Mode, "resource", cfg.Kind)
|
||||
|
||||
log.Info("Starting periodic data syncer")
|
||||
|
||||
@@ -47,62 +96,67 @@ func StartPeriodicDataSyncer(ctx context.Context, mode DualWriterMode, legacy Le
|
||||
time.Sleep(time.Second * time.Duration(jitterSeconds))
|
||||
|
||||
// run it immediately
|
||||
syncOK, err := runDataSyncer(ctx, mode, legacy, storage, kind, reg, serverLockService, requestInfo)
|
||||
syncOK, err := runDataSyncer(ctx, cfg)
|
||||
log.Info("data syncer finished", "syncOK", syncOK, "error", err)
|
||||
|
||||
ticker := time.NewTicker(dataSyncerInterval)
|
||||
ticker := time.NewTicker(cfg.DataSyncerInterval)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
syncOK, err = runDataSyncer(ctx, mode, legacy, storage, kind, reg, serverLockService, requestInfo)
|
||||
syncOK, err = runDataSyncer(ctx, cfg)
|
||||
log.Info("data syncer finished", "syncOK", syncOK, ", error", err)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDataSyncer will ensure that data between legacy storage and unified storage are in sync.
|
||||
// The sync implementation depends on the DualWriter mode
|
||||
func runDataSyncer(ctx context.Context, mode DualWriterMode, legacy LegacyStorage, storage Storage,
|
||||
kind string, reg prometheus.Registerer, serverLockService ServerLockService, requestInfo *request.RequestInfo) (bool, error) {
|
||||
func runDataSyncer(ctx context.Context, cfg *SyncerConfig) (bool, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return false, fmt.Errorf("invalid syncer config: %w", err)
|
||||
}
|
||||
// ensure that execution takes no longer than necessary
|
||||
const timeout = dataSyncerInterval - time.Minute
|
||||
timeout := cfg.DataSyncerInterval - time.Minute
|
||||
ctx, cancelFn := context.WithTimeout(ctx, timeout)
|
||||
defer cancelFn()
|
||||
|
||||
// implementation depends on the current DualWriter mode
|
||||
switch mode {
|
||||
switch cfg.Mode {
|
||||
case Mode1, Mode2:
|
||||
return legacyToUnifiedStorageDataSyncer(ctx, mode, legacy, storage, kind, reg, serverLockService, requestInfo)
|
||||
return legacyToUnifiedStorageDataSyncer(ctx, cfg)
|
||||
default:
|
||||
klog.Info("data syncer not implemented for mode mode:", mode)
|
||||
klog.Info("data syncer not implemented for mode:", cfg.Mode)
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func legacyToUnifiedStorageDataSyncer(ctx context.Context, mode DualWriterMode, legacy LegacyStorage, storage Storage, resource string, reg prometheus.Registerer, serverLockService ServerLockService, requestInfo *request.RequestInfo) (bool, error) {
|
||||
func legacyToUnifiedStorageDataSyncer(ctx context.Context, cfg *SyncerConfig) (bool, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return false, fmt.Errorf("invalid syncer config: %w", err)
|
||||
}
|
||||
metrics := &dualWriterMetrics{}
|
||||
metrics.init(reg)
|
||||
metrics.init(cfg.Reg)
|
||||
|
||||
log := klog.NewKlogr().WithName("legacyToUnifiedStorageDataSyncer").WithValues("mode", mode, "resource", resource)
|
||||
log := klog.NewKlogr().WithName("legacyToUnifiedStorageDataSyncer").WithValues("mode", cfg.Mode, "resource", cfg.Kind)
|
||||
|
||||
everythingSynced := false
|
||||
outOfSync := 0
|
||||
syncSuccess := 0
|
||||
syncErr := 0
|
||||
|
||||
maxInterval := dataSyncerInterval + 5*time.Minute
|
||||
maxInterval := cfg.DataSyncerInterval + 5*time.Minute
|
||||
|
||||
var errSync error
|
||||
const maxRecordsSync = 1000
|
||||
|
||||
// LockExecuteAndRelease ensures that just a single Grafana server acquires a lock at a time
|
||||
// The parameter 'maxInterval' is a timeout safeguard, if the LastExecution in the
|
||||
// database is older than maxInterval, we will assume the lock as timeouted. The 'maxInterval' parameter should be so long
|
||||
// that is impossible for 2 processes to run at the same time.
|
||||
err := serverLockService.LockExecuteAndRelease(ctx, fmt.Sprintf("legacyToUnifiedStorageDataSyncer-%d-%s", mode, resource), maxInterval, func(context.Context) {
|
||||
err := cfg.ServerLockService.LockExecuteAndRelease(ctx, fmt.Sprintf("legacyToUnifiedStorageDataSyncer-%d-%s", cfg.Mode, cfg.Kind), maxInterval, func(context.Context) {
|
||||
log.Info("starting legacyToUnifiedStorageDataSyncer")
|
||||
startSync := time.Now()
|
||||
|
||||
@@ -110,26 +164,26 @@ func legacyToUnifiedStorageDataSyncer(ctx context.Context, mode DualWriterMode,
|
||||
|
||||
ctx = klog.NewContext(ctx, log)
|
||||
ctx = identity.WithRequester(ctx, getSyncRequester(orgId))
|
||||
ctx = request.WithNamespace(ctx, requestInfo.Namespace)
|
||||
ctx = request.WithRequestInfo(ctx, requestInfo)
|
||||
ctx = request.WithNamespace(ctx, cfg.RequestInfo.Namespace)
|
||||
ctx = request.WithRequestInfo(ctx, cfg.RequestInfo)
|
||||
|
||||
storageList, err := getList(ctx, storage, &metainternalversion.ListOptions{
|
||||
Limit: maxRecordsSync,
|
||||
storageList, err := getList(ctx, cfg.Storage, &metainternalversion.ListOptions{
|
||||
Limit: int64(cfg.DataSyncerRecordsLimit),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(err, "unable to extract list from storage")
|
||||
return
|
||||
}
|
||||
|
||||
if len(storageList) >= maxRecordsSync {
|
||||
errSync = fmt.Errorf("unified storage has more than %d records. Aborting sync", maxRecordsSync)
|
||||
if len(storageList) >= cfg.DataSyncerRecordsLimit {
|
||||
errSync = fmt.Errorf("unified storage has more than %d records. Aborting sync", cfg.DataSyncerRecordsLimit)
|
||||
log.Error(errSync, "Unified storage has more records to be synced than allowed")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("got items from unified storage", "items", len(storageList))
|
||||
|
||||
legacyList, err := getList(ctx, legacy, &metainternalversion.ListOptions{})
|
||||
legacyList, err := getList(ctx, cfg.LegacyStorage, &metainternalversion.ListOptions{})
|
||||
if err != nil {
|
||||
log.Error(err, "unable to extract list from legacy storage")
|
||||
return
|
||||
@@ -189,7 +243,7 @@ func legacyToUnifiedStorageDataSyncer(ctx context.Context, mode DualWriterMode,
|
||||
}
|
||||
|
||||
objInfo := rest.DefaultUpdatedObjectInfo(item.objLegacy, []rest.TransformFunc{}...)
|
||||
res, _, err := storage.Update(ctx,
|
||||
res, _, err := cfg.Storage.Update(ctx,
|
||||
name,
|
||||
objInfo,
|
||||
func(ctx context.Context, obj runtime.Object) error { return nil },
|
||||
@@ -210,15 +264,15 @@ func legacyToUnifiedStorageDataSyncer(ctx context.Context, mode DualWriterMode,
|
||||
outOfSync++
|
||||
|
||||
ctx = request.WithRequestInfo(ctx, &request.RequestInfo{
|
||||
APIGroup: requestInfo.APIGroup,
|
||||
Resource: requestInfo.Resource,
|
||||
APIGroup: cfg.RequestInfo.APIGroup,
|
||||
Resource: cfg.RequestInfo.Resource,
|
||||
Name: name,
|
||||
Namespace: requestInfo.Namespace,
|
||||
Namespace: cfg.RequestInfo.Namespace,
|
||||
})
|
||||
|
||||
log.Info("deleting item from unified storage", "name", name)
|
||||
|
||||
deletedS, _, err := storage.Delete(ctx, name, func(ctx context.Context, obj runtime.Object) error { return nil }, &metav1.DeleteOptions{})
|
||||
deletedS, _, err := cfg.Storage.Delete(ctx, name, func(ctx context.Context, obj runtime.Object) error { return nil }, &metav1.DeleteOptions{})
|
||||
if err != nil && !apierrors.IsNotFound(err) {
|
||||
log.WithValues("objectList", deletedS).Error(err, "could not delete from storage")
|
||||
syncErr++
|
||||
@@ -231,8 +285,8 @@ func legacyToUnifiedStorageDataSyncer(ctx context.Context, mode DualWriterMode,
|
||||
|
||||
everythingSynced = outOfSync == syncSuccess
|
||||
|
||||
metrics.recordDataSyncerOutcome(mode, resource, everythingSynced)
|
||||
metrics.recordDataSyncerDuration(err != nil, mode, resource, startSync)
|
||||
metrics.recordDataSyncerOutcome(cfg.Mode, cfg.Kind, everythingSynced)
|
||||
metrics.recordDataSyncerDuration(err != nil, cfg.Mode, cfg.Kind, startSync)
|
||||
|
||||
log.Info("finished syncing items", "items", len(itemsByName), "updated", syncSuccess, "failed", syncErr, "outcome", everythingSynced)
|
||||
})
|
||||
|
||||
@@ -196,7 +196,18 @@ func TestLegacyToUnifiedStorage_DataSyncer(t *testing.T) {
|
||||
tt.setupStorageFn(um)
|
||||
}
|
||||
|
||||
outcome, err := legacyToUnifiedStorageDataSyncer(context.Background(), Mode1, ls, us, "test.kind", p, &fakeServerLock{}, &request.RequestInfo{})
|
||||
outcome, err := legacyToUnifiedStorageDataSyncer(context.Background(), &SyncerConfig{
|
||||
Mode: Mode1,
|
||||
LegacyStorage: ls,
|
||||
Storage: us,
|
||||
Kind: "test.kind",
|
||||
Reg: p,
|
||||
ServerLockService: &fakeServerLock{},
|
||||
RequestInfo: &request.RequestInfo{},
|
||||
|
||||
DataSyncerRecordsLimit: 1000,
|
||||
DataSyncerInterval: time.Hour,
|
||||
})
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
@@ -225,7 +236,18 @@ func TestLegacyToUnifiedStorage_DataSyncer(t *testing.T) {
|
||||
tt.setupStorageFn(um)
|
||||
}
|
||||
|
||||
outcome, err := legacyToUnifiedStorageDataSyncer(context.Background(), Mode2, ls, us, "test.kind", p, &fakeServerLock{}, &request.RequestInfo{})
|
||||
outcome, err := legacyToUnifiedStorageDataSyncer(context.Background(), &SyncerConfig{
|
||||
Mode: Mode2,
|
||||
LegacyStorage: ls,
|
||||
Storage: us,
|
||||
Kind: "test.kind",
|
||||
Reg: p,
|
||||
ServerLockService: &fakeServerLock{},
|
||||
RequestInfo: &request.RequestInfo{},
|
||||
|
||||
DataSyncerRecordsLimit: 1000,
|
||||
DataSyncerInterval: time.Hour,
|
||||
})
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
|
||||
@@ -65,7 +65,18 @@ func TestSetDualWritingMode(t *testing.T) {
|
||||
ls := legacyStoreMock{m, l}
|
||||
us := storageMock{m, s}
|
||||
|
||||
dwMode, err := SetDualWritingMode(context.Background(), tt.kvStore, ls, us, "playlist.grafana.app/playlists", tt.desiredMode, p, &fakeServerLock{}, &request.RequestInfo{})
|
||||
dwMode, err := SetDualWritingMode(context.Background(), tt.kvStore, &SyncerConfig{
|
||||
LegacyStorage: ls,
|
||||
Storage: us,
|
||||
Kind: "playlist.grafana.app/playlists",
|
||||
Mode: tt.desiredMode,
|
||||
ServerLockService: &fakeServerLock{},
|
||||
RequestInfo: &request.RequestInfo{},
|
||||
Reg: p,
|
||||
|
||||
DataSyncerRecordsLimit: 1000,
|
||||
DataSyncerInterval: time.Hour,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedMode, dwMode)
|
||||
}
|
||||
|
||||
@@ -186,12 +186,18 @@ func InstallAPIs(
|
||||
// when missing this will default to mode zero (legacy only)
|
||||
var mode = grafanarest.DualWriterMode(0)
|
||||
|
||||
var dualWriterPeriodicDataSyncJobEnabled bool
|
||||
var (
|
||||
dualWriterPeriodicDataSyncJobEnabled bool
|
||||
dataSyncerInterval = time.Hour
|
||||
dataSyncerRecordsLimit = 1000
|
||||
)
|
||||
|
||||
resourceConfig, resourceExists := storageOpts.UnifiedStorageConfig[key]
|
||||
if resourceExists {
|
||||
mode = resourceConfig.DualWriterMode
|
||||
dualWriterPeriodicDataSyncJobEnabled = resourceConfig.DualWriterPeriodicDataSyncJobEnabled
|
||||
dataSyncerInterval = resourceConfig.DataSyncerInterval
|
||||
dataSyncerRecordsLimit = resourceConfig.DataSyncerRecordsLimit
|
||||
}
|
||||
|
||||
// Force using storage only -- regardless of internal synchronization state
|
||||
@@ -205,7 +211,21 @@ func InstallAPIs(
|
||||
// Moving from one version to the next can only happen after the previous step has
|
||||
// successfully synchronized.
|
||||
requestInfo := getRequestInfo(gr, namespaceMapper)
|
||||
currentMode, err := grafanarest.SetDualWritingMode(ctx, kvStore, legacy, storage, key, mode, reg, serverLock, requestInfo)
|
||||
|
||||
syncerCfg := &grafanarest.SyncerConfig{
|
||||
Kind: key,
|
||||
RequestInfo: requestInfo,
|
||||
Mode: mode,
|
||||
LegacyStorage: legacy,
|
||||
Storage: storage,
|
||||
ServerLockService: serverLock,
|
||||
DataSyncerInterval: dataSyncerInterval,
|
||||
DataSyncerRecordsLimit: dataSyncerRecordsLimit,
|
||||
Reg: reg,
|
||||
}
|
||||
|
||||
// This also sets the currentMode on the syncer config.
|
||||
currentMode, err := grafanarest.SetDualWritingMode(ctx, kvStore, syncerCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -216,9 +236,12 @@ func InstallAPIs(
|
||||
return storage, nil
|
||||
default:
|
||||
}
|
||||
|
||||
if dualWriterPeriodicDataSyncJobEnabled {
|
||||
grafanarest.StartPeriodicDataSyncer(ctx, currentMode, legacy, storage, key, reg, serverLock, requestInfo)
|
||||
// The mode might have changed in SetDualWritingMode, so apply current mode first.
|
||||
syncerCfg.Mode = currentMode
|
||||
if err := grafanarest.StartPeriodicDataSyncer(ctx, syncerCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// when unable to use
|
||||
|
||||
@@ -4,11 +4,12 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/spf13/pflag"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
"k8s.io/apiserver/pkg/server/options"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
type StorageType string
|
||||
@@ -21,7 +22,8 @@ const (
|
||||
StorageTypeUnifiedGrpc StorageType = "unified-grpc"
|
||||
)
|
||||
|
||||
type StorageOptions struct { // The desired storage type
|
||||
type StorageOptions struct {
|
||||
// The desired storage type
|
||||
StorageType StorageType
|
||||
|
||||
// For unified-grpc, the address is required
|
||||
|
||||
@@ -1676,6 +1676,12 @@ var (
|
||||
Owner: grafanaOperatorExperienceSquad,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "elasticsearchCrossClusterSearch",
|
||||
Description: "Enables cross cluster search in the Elasticsearch datasource",
|
||||
Stage: FeatureStagePublicPreview,
|
||||
Owner: awsDatasourcesSquad,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -223,3 +223,4 @@ alertingUIOptimizeReducer,GA,@grafana/alerting-squad,false,false,true
|
||||
azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false
|
||||
alertingNotificationsStepMode,experimental,@grafana/alerting-squad,false,false,true
|
||||
feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
elasticsearchCrossClusterSearch,preview,@grafana/aws-datasources,false,false,false
|
||||
|
@@ -902,4 +902,8 @@ const (
|
||||
// FlagFeedbackButton
|
||||
// Enables a button to send feedback from the Grafana UI
|
||||
FlagFeedbackButton = "feedbackButton"
|
||||
|
||||
// FlagElasticsearchCrossClusterSearch
|
||||
// Enables cross cluster search in the Elasticsearch datasource
|
||||
FlagElasticsearchCrossClusterSearch = "elasticsearchCrossClusterSearch"
|
||||
)
|
||||
@@ -1269,6 +1269,21 @@
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "elasticsearchCrossClusterSearch",
|
||||
"resourceVersion": "1733848475752",
|
||||
"creationTimestamp": "2024-12-09T13:53:38Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-12-10 16:34:35.752111 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables cross cluster search in the Elasticsearch datasource",
|
||||
"stage": "preview",
|
||||
"codeowner": "@grafana/aws-datasources"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "enableDatagridEditing",
|
||||
|
||||
@@ -675,6 +675,7 @@ func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (
|
||||
|
||||
var nestedFolder *folder.Folder
|
||||
var dash *dashboards.Dashboard
|
||||
var f *folder.Folder
|
||||
err = s.db.InTransaction(ctx, func(ctx context.Context) error {
|
||||
if dash, err = s.dashboardStore.SaveDashboard(ctx, *saveDashboardCmd); err != nil {
|
||||
return toFolderError(err)
|
||||
@@ -696,17 +697,18 @@ func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (
|
||||
return err
|
||||
}
|
||||
|
||||
f = dashboards.FromDashboard(dash)
|
||||
if nestedFolder != nil && nestedFolder.ParentUID != "" {
|
||||
f.ParentUID = nestedFolder.ParentUID
|
||||
}
|
||||
if err = s.setDefaultFolderPermissions(ctx, cmd.OrgID, user, f); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f := dashboards.FromDashboard(dash)
|
||||
if nestedFolder != nil && nestedFolder.ParentUID != "" {
|
||||
f.ParentUID = nestedFolder.ParentUID
|
||||
}
|
||||
if err = s.setDefaultFolderPermissions(ctx, cmd.OrgID, user, f); err != nil {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -271,6 +271,16 @@ func (rs *RenderingService) Render(ctx context.Context, renderType RenderType, o
|
||||
}
|
||||
|
||||
func (rs *RenderingService) render(ctx context.Context, renderType RenderType, opts Opts, renderKeyProvider renderKeyProvider) (*RenderResult, error) {
|
||||
if !rs.IsAvailable(ctx) {
|
||||
rs.log.Warn("Could not render image, no image renderer found/installed. " +
|
||||
"For image rendering support please install the grafana-image-renderer plugin. " +
|
||||
"Read more at https://grafana.com/docs/grafana/latest/administration/image_rendering/")
|
||||
if opts.ErrorRenderUnavailable {
|
||||
return nil, ErrRenderUnavailable
|
||||
}
|
||||
return rs.renderUnavailableImage(), nil
|
||||
}
|
||||
|
||||
if int(atomic.LoadInt32(&rs.inProgressCount)) > opts.ConcurrentLimit {
|
||||
rs.log.Warn("Could not render image, hit the currency limit", "concurrencyLimit", opts.ConcurrentLimit, "path", opts.Path)
|
||||
if opts.ErrorConcurrentLimitReached {
|
||||
@@ -287,15 +297,10 @@ func (rs *RenderingService) render(ctx context.Context, renderType RenderType, o
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !rs.IsAvailable(ctx) {
|
||||
rs.log.Warn("Could not render image, no image renderer found/installed. " +
|
||||
"For image rendering support please install the grafana-image-renderer plugin. " +
|
||||
"Read more at https://grafana.com/docs/grafana/latest/administration/image_rendering/")
|
||||
if opts.ErrorRenderUnavailable {
|
||||
return nil, ErrRenderUnavailable
|
||||
}
|
||||
return rs.renderUnavailableImage(), nil
|
||||
}
|
||||
defer func() {
|
||||
metrics.MRenderingQueue.Set(float64(atomic.AddInt32(&rs.inProgressCount, -1)))
|
||||
}()
|
||||
metrics.MRenderingQueue.Set(float64(atomic.AddInt32(&rs.inProgressCount, 1)))
|
||||
|
||||
if renderType == RenderPDF {
|
||||
if !rs.features.IsEnabled(ctx, featuremgmt.FlagNewPDFRendering) {
|
||||
@@ -318,12 +323,14 @@ func (rs *RenderingService) render(ctx context.Context, renderType RenderType, o
|
||||
|
||||
defer renderKeyProvider.afterRequest(ctx, opts.AuthOpts, renderKey)
|
||||
|
||||
defer func() {
|
||||
metrics.MRenderingQueue.Set(float64(atomic.AddInt32(&rs.inProgressCount, -1)))
|
||||
}()
|
||||
res, err := rs.renderAction(ctx, renderType, renderKey, opts)
|
||||
if err != nil {
|
||||
rs.log.Error("Failed to render image", "path", opts.Path, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
rs.log.Debug("Successfully rendered image", "path", opts.Path)
|
||||
|
||||
metrics.MRenderingQueue.Set(float64(atomic.AddInt32(&rs.inProgressCount, 1)))
|
||||
return rs.renderAction(ctx, renderType, renderKey, opts)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (rs *RenderingService) RenderCSV(ctx context.Context, opts CSVOpts, session Session) (*RenderCSVResult, error) {
|
||||
|
||||
@@ -121,7 +121,8 @@ func TestRenderLimitImage(t *testing.T) {
|
||||
|
||||
rs := RenderingService{
|
||||
Cfg: &setting.Cfg{
|
||||
HomePath: path,
|
||||
HomePath: path,
|
||||
RendererUrl: "http://localhost:8081/render",
|
||||
},
|
||||
inProgressCount: 2,
|
||||
log: log.New("test"),
|
||||
@@ -161,7 +162,9 @@ func TestRenderLimitImage(t *testing.T) {
|
||||
|
||||
func TestRenderLimitImageError(t *testing.T) {
|
||||
rs := RenderingService{
|
||||
Cfg: &setting.Cfg{},
|
||||
Cfg: &setting.Cfg{
|
||||
RendererUrl: "http://localhost:8081/render",
|
||||
},
|
||||
inProgressCount: 2,
|
||||
log: log.New("test"),
|
||||
}
|
||||
|
||||
@@ -539,6 +539,10 @@ type Cfg struct {
|
||||
type UnifiedStorageConfig struct {
|
||||
DualWriterMode rest.DualWriterMode
|
||||
DualWriterPeriodicDataSyncJobEnabled bool
|
||||
// DataSyncerInterval defines how often the data syncer should run for a resource on the grafana instance.
|
||||
DataSyncerInterval time.Duration
|
||||
// DataSyncerRecordsLimit defines how many records will be processed at max during a sync invocation.
|
||||
DataSyncerRecordsLimit int
|
||||
}
|
||||
|
||||
type InstallPlugin struct {
|
||||
|
||||
@@ -2,6 +2,7 @@ package setting
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
)
|
||||
@@ -29,9 +30,17 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
|
||||
// parse dualWriter periodic data syncer config
|
||||
dualWriterPeriodicDataSyncJobEnabled := section.Key("dualWriterPeriodicDataSyncJobEnabled").MustBool(false)
|
||||
|
||||
// parse dataSyncerRecordsLimit from resource section
|
||||
dataSyncerRecordsLimit := section.Key("dataSyncerRecordsLimit").MustInt(1000)
|
||||
|
||||
// parse dataSyncerInterval from resource section
|
||||
dataSyncerInterval := section.Key("dataSyncerInterval").MustDuration(time.Hour)
|
||||
|
||||
storageConfig[resourceName] = UnifiedStorageConfig{
|
||||
DualWriterMode: rest.DualWriterMode(dualWriterMode),
|
||||
DualWriterPeriodicDataSyncJobEnabled: dualWriterPeriodicDataSyncJobEnabled,
|
||||
DataSyncerRecordsLimit: dataSyncerRecordsLimit,
|
||||
DataSyncerInterval: dataSyncerInterval,
|
||||
}
|
||||
}
|
||||
cfg.UnifiedStorage = storageConfig
|
||||
|
||||
@@ -2,6 +2,7 @@ package setting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -21,6 +22,12 @@ func TestCfg_setUnifiedStorageConfig(t *testing.T) {
|
||||
_, err = s.NewKey("dualWriterPeriodicDataSyncJobEnabled", "true")
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = s.NewKey("dataSyncerRecordsLimit", "1001")
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = s.NewKey("dataSyncerInterval", "10m")
|
||||
assert.NoError(t, err)
|
||||
|
||||
cfg.setUnifiedStorageConfig()
|
||||
|
||||
value, exists := cfg.UnifiedStorage["playlists.playlist.grafana.app"]
|
||||
@@ -29,6 +36,8 @@ func TestCfg_setUnifiedStorageConfig(t *testing.T) {
|
||||
assert.Equal(t, value, UnifiedStorageConfig{
|
||||
DualWriterMode: 2,
|
||||
DualWriterPeriodicDataSyncJobEnabled: true,
|
||||
DataSyncerRecordsLimit: 1001,
|
||||
DataSyncerInterval: time.Minute * 10,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -182,14 +182,21 @@ func (s *Service) getDSInfo(ctx context.Context, pluginCtx backend.PluginContext
|
||||
return &instance, nil
|
||||
}
|
||||
|
||||
func isFieldCaps(url string) bool {
|
||||
return strings.HasSuffix(url, "/_field_caps") || url == "_field_caps"
|
||||
}
|
||||
|
||||
func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
logger := s.logger.FromContext(ctx)
|
||||
// allowed paths for resource calls:
|
||||
// - empty string for fetching db version
|
||||
// - /_mapping for fetching index mapping, e.g. requests going to `index/_mapping`
|
||||
// - /_field_caps for fetching field capabilities, e.g. requests going to `index/_field_caps`
|
||||
// - _msearch for executing getTerms queries
|
||||
// - _mapping for fetching "root" index mappings
|
||||
if req.Path != "" && !strings.HasSuffix(req.Path, "/_mapping") && req.Path != "_msearch" && req.Path != "_mapping" {
|
||||
// - _field_caps for fetching "root" field capabilities
|
||||
if req.Path != "" && !isFieldCaps(req.Path) && req.Path != "_msearch" &&
|
||||
!strings.HasSuffix(req.Path, "/_mapping") && req.Path != "_mapping" {
|
||||
logger.Error("Invalid resource path", "path", req.Path)
|
||||
return fmt.Errorf("invalid resource URL: %s", req.Path)
|
||||
}
|
||||
@@ -266,6 +273,9 @@ func createElasticsearchURL(req *backend.CallResourceRequest, ds *es.DatasourceI
|
||||
}
|
||||
|
||||
esUrl.Path = path.Join(esUrl.Path, req.Path)
|
||||
if isFieldCaps(req.Path) {
|
||||
esUrl.RawQuery = "fields=*"
|
||||
}
|
||||
esUrlString := esUrl.String()
|
||||
// If the request path is empty and the URL does not end with a slash, add a slash to the URL.
|
||||
// This ensures that for version checks executed to the root URL, the URL ends with a slash.
|
||||
|
||||
@@ -109,7 +109,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
|
||||
"from": []string{from},
|
||||
"until": []string{until},
|
||||
"format": []string{"json"},
|
||||
"maxDataPoints": []string{"500"},
|
||||
"maxDataPoints": []string{fmt.Sprintf("%d", q.MaxDataPoints)},
|
||||
"target": []string{},
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { getWrapper, render, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils';
|
||||
import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||
|
||||
import { AccessControlAction } from '../../../types';
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
|
||||
import { CloneRuleEditor, cloneRuleDefinition } from './CloneRuleEditor';
|
||||
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
|
||||
import { mockFeatureDiscoveryApi, setupMswServer } from './mockApi';
|
||||
import { setupMswServer } from './mockApi';
|
||||
import {
|
||||
grantUserPermissions,
|
||||
mockDataSource,
|
||||
@@ -27,7 +28,6 @@ import { grafanaRulerRule } from './mocks/grafanaRulerApi';
|
||||
import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from './mocks/rulerApi';
|
||||
import { AlertingQueryRunner } from './state/AlertingQueryRunner';
|
||||
import { setupDataSources } from './testSetup/datasources';
|
||||
import { buildInfoResponse } from './testSetup/featureDiscovery';
|
||||
import { RuleFormValues } from './types/rule-form';
|
||||
import { Annotation } from './utils/constants';
|
||||
import { getDefaultFormValues } from './utils/rule-form';
|
||||
@@ -107,10 +107,10 @@ describe('CloneRuleEditor', function () {
|
||||
it('should populate form values from the existing alert rule', async function () {
|
||||
const dsSettings = mockDataSource({
|
||||
name: 'my-prom-ds',
|
||||
uid: 'my-prom-ds',
|
||||
uid: MIMIR_DATASOURCE_UID,
|
||||
});
|
||||
setupDataSources(dsSettings);
|
||||
mockFeatureDiscoveryApi(server).discoverDsFeatures(dsSettings, buildInfoResponse.mimir);
|
||||
|
||||
const originRule = mockRulerAlertingRule({
|
||||
for: '1m',
|
||||
alert: 'First Ruler Rule',
|
||||
@@ -119,11 +119,11 @@ describe('CloneRuleEditor', function () {
|
||||
annotations: { [Annotation.summary]: 'This is a very important alert rule' },
|
||||
});
|
||||
|
||||
mockRulerRulesApiResponse(server, 'my-prom-ds', {
|
||||
mockRulerRulesApiResponse(server, MIMIR_DATASOURCE_UID, {
|
||||
'namespace-one': [{ name: 'group1', interval: '20s', rules: [originRule] }],
|
||||
});
|
||||
|
||||
mockRulerRulesGroupApiResponse(server, 'my-prom-ds', 'namespace-one', 'group1', {
|
||||
mockRulerRulesGroupApiResponse(server, MIMIR_DATASOURCE_UID, 'namespace-one', 'group1', {
|
||||
name: 'group1',
|
||||
interval: '20s',
|
||||
rules: [originRule],
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
makeAllAlertmanagerConfigFetchFail,
|
||||
makeAllK8sGetEndpointsFail,
|
||||
} from 'app/features/alerting/unified/mocks/server/configure';
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import {
|
||||
getAlertmanagerConfig,
|
||||
setAlertmanagerConfig,
|
||||
@@ -97,9 +98,9 @@ const dataSources = {
|
||||
},
|
||||
}),
|
||||
mimir: mockDataSource<AlertManagerDataSourceJsonData>({
|
||||
name: 'mimir',
|
||||
name: 'MimirAlertmanager',
|
||||
type: DataSourceType.Alertmanager,
|
||||
uid: 'mimir',
|
||||
uid: MIMIR_DATASOURCE_UID,
|
||||
jsonData: {
|
||||
implementation: AlertManagerImplementation.mimir,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { render, screen } from 'test/test-utils';
|
||||
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { setFolderResponse } from 'app/features/alerting/unified/mocks/server/configure';
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { captureRequests } from 'app/features/alerting/unified/mocks/server/events';
|
||||
import { DashboardSearchItemType } from 'app/features/search/types';
|
||||
|
||||
@@ -75,7 +76,7 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
|
||||
const dataSources = {
|
||||
default: mockDataSource({
|
||||
uid: 'mimir',
|
||||
uid: MIMIR_DATASOURCE_UID,
|
||||
type: 'prometheus',
|
||||
name: 'Mimir',
|
||||
isDefault: true,
|
||||
|
||||
@@ -5,13 +5,13 @@ import { screen } from 'test/test-utils';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { mockFeatureDiscoveryApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { PROMETHEUS_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { grantUserPermissions, mockDataSource } from './mocks';
|
||||
import { grafanaRulerGroup } from './mocks/grafanaRulerApi';
|
||||
import { setupDataSources } from './testSetup/datasources';
|
||||
import { buildInfoResponse } from './testSetup/featureDiscovery';
|
||||
|
||||
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
|
||||
AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>,
|
||||
@@ -19,7 +19,7 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
|
||||
|
||||
jest.setTimeout(60 * 1000);
|
||||
|
||||
const server = setupMswServer();
|
||||
setupMswServer();
|
||||
|
||||
describe('RuleEditor grafana managed rules', () => {
|
||||
beforeEach(() => {
|
||||
@@ -47,6 +47,7 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
{
|
||||
type: 'prometheus',
|
||||
name: 'Prom',
|
||||
uid: PROMETHEUS_DATASOURCE_UID,
|
||||
isDefault: true,
|
||||
},
|
||||
{ alerting: false }
|
||||
@@ -54,7 +55,6 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
};
|
||||
|
||||
setupDataSources(dataSources.default);
|
||||
mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir);
|
||||
|
||||
const { user } = renderRuleEditor();
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ If you don't find a helper for an endpoint you're looking for, please add it.
|
||||
|
||||
### Common API requests
|
||||
|
||||
- `/buildinfo`
|
||||
Use `mockFeatureDiscoveryApi` and `buildInfoResponse` object to mock the endpoint response according to your needs
|
||||
- `api/v1/eval` used by AlertingQueryRunner
|
||||
Use `mockApi.eval` Usually an empty response should do the trick
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ exports[`RuleEditor cloud can create a new cloud alert 1`] = `
|
||||
],
|
||||
],
|
||||
"method": "GET",
|
||||
"url": "https://mimir.local:9000/api/v1/status/buildinfo",
|
||||
"url": "http://localhost/api/datasources/proxy/uid/mimir/api/v1/status/buildinfo",
|
||||
},
|
||||
{
|
||||
"body": "",
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ exports[`RuleEditor recording rules can create a new cloud recording rule 1`] =
|
||||
],
|
||||
],
|
||||
"method": "GET",
|
||||
"url": "https://mimir.local:9000/api/v1/status/buildinfo",
|
||||
"url": "http://localhost/api/datasources/proxy/uid/mimir/api/v1/status/buildinfo",
|
||||
},
|
||||
{
|
||||
"body": "",
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ import { ComponentProps, ReactNode } from 'react';
|
||||
import { render, screen, userEvent, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { flushMicrotasks, testWithFeatureToggles } from 'app/features/alerting/unified/test/test-utils';
|
||||
import { K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants';
|
||||
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
|
||||
@@ -16,7 +17,7 @@ import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasourc
|
||||
|
||||
import { ContactPoint } from './ContactPoint';
|
||||
import { ContactPointsPageContents } from './ContactPoints';
|
||||
import setupMimirFlavoredServer, { MIMIR_DATASOURCE_UID } from './__mocks__/mimirFlavoredServer';
|
||||
import setupMimirFlavoredServer from './__mocks__/mimirFlavoredServer';
|
||||
import setupVanillaAlertmanagerFlavoredServer, {
|
||||
VANILLA_ALERTMANAGER_DATASOURCE_UID,
|
||||
} from './__mocks__/vanillaAlertmanagerServer';
|
||||
|
||||
+2
-1
@@ -1,13 +1,14 @@
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { SetupServer } from 'msw/node';
|
||||
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import mimirAlertmanagerMock from './alertmanager.mimir.config.mock.json';
|
||||
|
||||
// this one emulates a mimir server setup
|
||||
export const MIMIR_DATASOURCE_UID = 'mimir';
|
||||
|
||||
/** @deprecated Move to main alerting MSW server instead */
|
||||
export default (server: SetupServer) => {
|
||||
server.use(
|
||||
http.get(`/api/alertmanager/${MIMIR_DATASOURCE_UID}/config/api/v1/alerts`, () =>
|
||||
|
||||
+4
-5
@@ -8,14 +8,14 @@ import { byRole } from 'testing-library-selector';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import RuleEditor from 'app/features/alerting/unified/RuleEditor';
|
||||
import { mockFeatureDiscoveryApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks';
|
||||
import { setAlertmanagerChoices } from 'app/features/alerting/unified/mocks/server/configure';
|
||||
import { PROMETHEUS_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { captureRequests, serializeRequests } from 'app/features/alerting/unified/mocks/server/events';
|
||||
import { FOLDER_TITLE_HAPPY_PATH } from 'app/features/alerting/unified/mocks/server/handlers/search';
|
||||
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
|
||||
import { testWithFeatureToggles } from 'app/features/alerting/unified/test/test-utils';
|
||||
import { buildInfoResponse } from 'app/features/alerting/unified/testSetup/featureDiscovery';
|
||||
import { DataSourceType, GRAFANA_DATASOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
@@ -29,13 +29,14 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
|
||||
|
||||
jest.setTimeout(60 * 1000);
|
||||
|
||||
const server = setupMswServer();
|
||||
setupMswServer();
|
||||
|
||||
const dataSources = {
|
||||
default: mockDataSource(
|
||||
{
|
||||
type: 'prometheus',
|
||||
name: 'Prom',
|
||||
uid: PROMETHEUS_DATASOURCE_UID,
|
||||
isDefault: true,
|
||||
},
|
||||
{ alerting: false }
|
||||
@@ -78,8 +79,6 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
AccessControlAction.AlertingNotificationsRead,
|
||||
AccessControlAction.AlertingNotificationsWrite,
|
||||
]);
|
||||
|
||||
mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir);
|
||||
});
|
||||
|
||||
it('cannot create new grafana managed alert when using simplified routing and not selecting a contact point', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { config, locationService, setPluginLinksHook } from '@grafana/runtime';
|
||||
import { interceptLinkClicks } from 'app/core/navigation/patch/interceptLinkClicks';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { RuleActionsButtons } from 'app/features/alerting/unified/components/rules/RuleActionsButtons';
|
||||
import { mockFeatureDiscoveryApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import {
|
||||
getCloudRule,
|
||||
getGrafanaRule,
|
||||
@@ -14,19 +14,17 @@ import {
|
||||
mockGrafanaRulerRule,
|
||||
mockPromAlertingRule,
|
||||
} from 'app/features/alerting/unified/mocks';
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { setupDataSources } from '../../testSetup/datasources';
|
||||
import { buildInfoResponse } from '../../testSetup/featureDiscovery';
|
||||
import { fromCombinedRule, stringifyIdentifier } from '../../utils/rule-id';
|
||||
|
||||
const server = setupMswServer();
|
||||
setupMswServer();
|
||||
jest.mock('app/core/services/context_srv');
|
||||
const mockContextSrv = jest.mocked(contextSrv);
|
||||
|
||||
// const locationPushSpy = jest.spyOn(locationService, 'push');
|
||||
|
||||
const ui = {
|
||||
detailsButton: byRole('link', { name: /View/ }),
|
||||
menu: byRole('menu'),
|
||||
@@ -66,7 +64,7 @@ setPluginLinksHook(() => ({
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
const mimirDs = mockDataSource({ uid: 'mimir', name: 'Mimir' });
|
||||
const mimirDs = mockDataSource({ uid: MIMIR_DATASOURCE_UID, name: 'Mimir' });
|
||||
const prometheusDs = mockDataSource({ uid: 'prometheus', name: 'Prometheus' });
|
||||
setupDataSources(mimirDs, prometheusDs);
|
||||
|
||||
@@ -106,7 +104,6 @@ describe('RuleActionsButtons', () => {
|
||||
const user = userEvent.setup();
|
||||
grantAllPermissions();
|
||||
const mockRule = getCloudRule(undefined, { rulesSource: mimirDs });
|
||||
mockFeatureDiscoveryApi(server).discoverDsFeatures(mimirDs, buildInfoResponse.mimir);
|
||||
|
||||
render(<RuleActionsButtons rule={mockRule} rulesSource={mimirDs} />);
|
||||
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ exports[`reorder rules for rule group should correctly reorder rules 1`] = `
|
||||
],
|
||||
],
|
||||
"method": "GET",
|
||||
"url": "https://mimir.local:9000/api/v1/status/buildinfo",
|
||||
"url": "http://localhost/api/datasources/proxy/uid/mimir/api/v1/status/buildinfo",
|
||||
},
|
||||
{
|
||||
"body": "",
|
||||
@@ -89,7 +89,7 @@ exports[`useUpdateRuleGroupConfiguration should be able to move a Data Source ma
|
||||
],
|
||||
],
|
||||
"method": "GET",
|
||||
"url": "https://mimir.local:9000/api/v1/status/buildinfo",
|
||||
"url": "http://localhost/api/datasources/proxy/uid/mimir/api/v1/status/buildinfo",
|
||||
},
|
||||
{
|
||||
"body": "",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getWrapper, render, renderHook, screen, waitFor } from 'test/test-utils
|
||||
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { setFolderAccessControl } from 'app/features/alerting/unified/mocks/server/configure';
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
@@ -86,7 +87,7 @@ describe('alertmanager abilities', () => {
|
||||
it('should report everything except exporting for Mimir alertmanager', () => {
|
||||
setupDataSources(
|
||||
mockDataSource<AlertManagerDataSourceJsonData>({
|
||||
name: 'mimir',
|
||||
name: MIMIR_DATASOURCE_UID,
|
||||
type: DataSourceType.Alertmanager,
|
||||
jsonData: {
|
||||
implementation: AlertManagerImplementation.mimir,
|
||||
|
||||
@@ -3,13 +3,13 @@ import * as React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import { AccessControlAction, FolderDTO } from 'app/types';
|
||||
|
||||
import { mockFeatureDiscoveryApi, setupMswServer } from '../mockApi';
|
||||
import { setupMswServer } from '../mockApi';
|
||||
import { mockDataSource, mockFolder, mockRulerAlertingRule, mockRulerGrafanaRule } from '../mocks';
|
||||
import { setupDataSources } from '../testSetup/datasources';
|
||||
import { buildInfoResponse } from '../testSetup/featureDiscovery';
|
||||
|
||||
import { useFolder } from './useFolder';
|
||||
import { useIsRuleEditable } from './useIsRuleEditable';
|
||||
@@ -20,10 +20,10 @@ const mocks = {
|
||||
useFolder: jest.mocked(useFolder),
|
||||
};
|
||||
|
||||
const server = setupMswServer();
|
||||
setupMswServer();
|
||||
|
||||
const dataSources = {
|
||||
mimir: mockDataSource({ uid: 'mimir', name: 'Mimir' }),
|
||||
mimir: mockDataSource({ uid: MIMIR_DATASOURCE_UID, name: 'Mimir' }),
|
||||
};
|
||||
|
||||
setupDataSources(dataSources.mimir);
|
||||
@@ -107,8 +107,6 @@ describe('useIsRuleEditable', () => {
|
||||
beforeEach(() => {
|
||||
mocks.useFolder.mockReturnValue({ loading: false });
|
||||
contextSrv.isEditor = true;
|
||||
|
||||
mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.mimir, buildInfoResponse.mimir);
|
||||
});
|
||||
|
||||
it('Should allow editing and deleting when the user has alert rule external write permission', async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { SetupServer, setupServer } from 'msw/node';
|
||||
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { setBackendSrv } from '@grafana/runtime';
|
||||
import { AlertGroupUpdated } from 'app/features/alerting/unified/api/alertRuleApi';
|
||||
import allHandlers from 'app/features/alerting/unified/mocks/server/all-handlers';
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
import { resetRoutingTreeMap } from 'app/features/alerting/unified/mocks/server/entities/k8s/routingtrees';
|
||||
import { DashboardDTO, FolderDTO, OrgUser } from 'app/types';
|
||||
import {
|
||||
PromBuildInfoResponse,
|
||||
PromRulesResponse,
|
||||
RulerGrafanaRuleDTO,
|
||||
RulerRuleGroupDTO,
|
||||
@@ -189,22 +187,6 @@ export function mockAlertRuleApi(server: SetupServer) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to mock the response from the /api/v1/status/buildinfo endpoint
|
||||
*/
|
||||
export function mockFeatureDiscoveryApi(server: SetupServer) {
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param dsSettings Use `mockDataSource` to create a faks data source settings
|
||||
* @param response Use `buildInfoResponse` to get a pre-defined response for Prometheus and Mimir
|
||||
*/
|
||||
discoverDsFeatures: (dsSettings: DataSourceInstanceSettings, response: PromBuildInfoResponse) => {
|
||||
server.use(http.get(`${dsSettings.url}/api/v1/status/buildinfo`, () => HttpResponse.json(response)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mockExportApi(server: SetupServer) {
|
||||
// exportRule, exportRulesGroup, exportRulesFolder use the same API endpoint but with different parameters
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { HttpResponse, http } from 'msw';
|
||||
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import server, { mockFeatureDiscoveryApi } from 'app/features/alerting/unified/mockApi';
|
||||
import server from 'app/features/alerting/unified/mockApi';
|
||||
import { mockDataSource, mockFolder } from 'app/features/alerting/unified/mocks';
|
||||
import {
|
||||
getAlertmanagerConfigHandler,
|
||||
@@ -23,7 +23,6 @@ import { FolderDTO } from 'app/types';
|
||||
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { setupDataSources } from '../../testSetup/datasources';
|
||||
import { buildInfoResponse } from '../../testSetup/featureDiscovery';
|
||||
import { DataSourceType } from '../../utils/datasource';
|
||||
import { ApiMachineryError } from '../../utils/k8s/errors';
|
||||
|
||||
@@ -101,7 +100,6 @@ export function mimirDataSource() {
|
||||
type: DataSourceType.Prometheus,
|
||||
name: MIMIR_DATASOURCE_UID,
|
||||
uid: MIMIR_DATASOURCE_UID,
|
||||
url: 'https://mimir.local:9000',
|
||||
jsonData: {
|
||||
manageAlerts: true,
|
||||
implementation: 'mimir',
|
||||
@@ -111,7 +109,6 @@ export function mimirDataSource() {
|
||||
);
|
||||
|
||||
setupDataSources(dataSource);
|
||||
mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSource, buildInfoResponse.mimir);
|
||||
|
||||
return { dataSource };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const MIMIR_DATASOURCE_UID = 'mimir';
|
||||
export const MIMIR_GROUP_NAME = 'group-1';
|
||||
export const MIMIR_NAMESPACE_NAME = 'namespace-1';
|
||||
|
||||
export const PROMETHEUS_DATASOURCE_UID = 'prometheus';
|
||||
@@ -34,6 +34,7 @@ beforeEach(() => {
|
||||
const io = mockIntersectionObserver();
|
||||
|
||||
describe('RuleList - FilterView', () => {
|
||||
jest.retryTimes(2);
|
||||
it('should render multiple pages of results', async () => {
|
||||
render(<FilterView filterState={getFilter({ dataSourceNames: ['Mimir'] })} />);
|
||||
|
||||
@@ -53,12 +54,11 @@ describe('RuleList - FilterView', () => {
|
||||
|
||||
await loadMoreResults();
|
||||
|
||||
const matchingRule = await screen.findByRole('treeitem', {
|
||||
name: /test-rule-8 test-mimir-namespace test-group-4501/,
|
||||
});
|
||||
const matchingRule = (await screen.findAllByRole('treeitem')).at(0);
|
||||
expect(matchingRule).toBeInTheDocument();
|
||||
|
||||
expect(matchingRule).toHaveTextContent('test-rule-8');
|
||||
expect(matchingRule).toHaveTextContent('test-mimir-namespace');
|
||||
expect(matchingRule).toHaveTextContent('test-group-4501');
|
||||
expect(await screen.findByText(/No more results/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -24,14 +24,14 @@ describe('DashboardPrompt', () => {
|
||||
describe('when called without original dashboard', () => {
|
||||
it('then it should return true', () => {
|
||||
const scene = buildTestScene();
|
||||
expect(ignoreChanges(scene, undefined)).toBe(true);
|
||||
scene.setInitialSaveModel(undefined);
|
||||
expect(ignoreChanges(scene)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called without current dashboard', () => {
|
||||
it('then it should return true', () => {
|
||||
const scene = buildTestScene();
|
||||
expect(ignoreChanges(null, scene.getInitialSaveModel())).toBe(true);
|
||||
expect(ignoreChanges(null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('DashboardPrompt', () => {
|
||||
});
|
||||
contextSrv.isEditor = false;
|
||||
|
||||
expect(ignoreChanges(scene, scene.getInitialSaveModel())).toBe(true);
|
||||
expect(ignoreChanges(scene)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,10 +59,11 @@ describe('DashboardPrompt', () => {
|
||||
},
|
||||
});
|
||||
const initialSaveModel = transformSceneToSaveModel(scene);
|
||||
scene.setInitialSaveModel(initialSaveModel);
|
||||
|
||||
contextSrv.isEditor = false;
|
||||
|
||||
expect(ignoreChanges(scene, initialSaveModel)).toBe(undefined);
|
||||
expect(ignoreChanges(scene)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,9 +76,10 @@ describe('DashboardPrompt', () => {
|
||||
},
|
||||
});
|
||||
const initialSaveModel = transformSceneToSaveModel(scene);
|
||||
scene.setInitialSaveModel(initialSaveModel);
|
||||
|
||||
contextSrv.isSignedIn = false;
|
||||
expect(ignoreChanges(scene, initialSaveModel)).toBe(true);
|
||||
expect(ignoreChanges(scene)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +92,9 @@ describe('DashboardPrompt', () => {
|
||||
},
|
||||
});
|
||||
const initialSaveModel = transformSceneToSaveModel(scene);
|
||||
expect(ignoreChanges(scene, initialSaveModel)).toBe(true);
|
||||
scene.setInitialSaveModel(initialSaveModel);
|
||||
|
||||
expect(ignoreChanges(scene)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,7 +108,9 @@ describe('DashboardPrompt', () => {
|
||||
},
|
||||
});
|
||||
const initialSaveModel = transformSceneToSaveModel(scene);
|
||||
expect(ignoreChanges(scene, initialSaveModel)).toBe(true);
|
||||
scene.setInitialSaveModel(initialSaveModel);
|
||||
|
||||
expect(ignoreChanges(scene)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,7 +124,9 @@ describe('DashboardPrompt', () => {
|
||||
},
|
||||
});
|
||||
const initialSaveModel = transformSceneToSaveModel(scene);
|
||||
expect(ignoreChanges(scene, initialSaveModel)).toBe(undefined);
|
||||
scene.setInitialSaveModel(initialSaveModel);
|
||||
|
||||
expect(ignoreChanges(scene)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,12 @@ import * as H from 'history';
|
||||
import { memo, useContext, useEffect, useMemo } from 'react';
|
||||
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
|
||||
import { ModalsContext, Modal, Button, useStyles2 } from '@grafana/ui';
|
||||
import { Prompt } from 'app/core/components/FormPrompt/Prompt';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
import { SaveLibraryVizPanelModal } from '../panel-edit/SaveLibraryVizPanelModal';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { DashboardScene, isV2Dashboard } from '../scene/DashboardScene';
|
||||
import { getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils';
|
||||
|
||||
interface DashboardPromptProps {
|
||||
@@ -23,7 +22,7 @@ export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleUnload = (event: BeforeUnloadEvent) => {
|
||||
if (ignoreChanges(dashboard, dashboard.getInitialSaveModel())) {
|
||||
if (ignoreChanges(dashboard)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,7 +71,7 @@ export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ignoreChanges(dashboard, dashboard.getInitialSaveModel())) {
|
||||
if (ignoreChanges(dashboard)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -153,7 +152,13 @@ const getStyles = () => ({
|
||||
/**
|
||||
* For some dashboards and users changes should be ignored *
|
||||
*/
|
||||
export function ignoreChanges(current: DashboardScene | null, original?: Dashboard) {
|
||||
export function ignoreChanges(scene: DashboardScene | null) {
|
||||
const original = scene?.getInitialSaveModel();
|
||||
|
||||
if (original && isV2Dashboard(original)) {
|
||||
throw new Error('isV2Dashboard is not implemented');
|
||||
}
|
||||
|
||||
if (!original) {
|
||||
return true;
|
||||
}
|
||||
@@ -168,11 +173,11 @@ export function ignoreChanges(current: DashboardScene | null, original?: Dashboa
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
if (!scene) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { canSave, fromScript, fromFile } = current.state.meta;
|
||||
const { canSave, fromScript, fromFile } = scene.state.meta;
|
||||
if (!contextSrv.isEditor && !canSave) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
|
||||
import { getDashboardChanges } from './getDashboardChanges';
|
||||
import { getRawDashboardChanges } from './getDashboardChanges';
|
||||
|
||||
function _debounce<T>(f: (...args: T[]) => void, timeout: number) {
|
||||
let timeoutId: NodeJS.Timeout | undefined = undefined;
|
||||
@@ -13,6 +13,6 @@ function _debounce<T>(f: (...args: T[]) => void, timeout: number) {
|
||||
}
|
||||
|
||||
self.onmessage = _debounce((e: MessageEvent<{ initial: Dashboard; changed: Dashboard }>) => {
|
||||
const result = getDashboardChanges(e.data.initial, e.data.changed, false, false, false);
|
||||
const result = getRawDashboardChanges(e.data.initial, e.data.changed, false, false, false);
|
||||
self.postMessage(result);
|
||||
}, 500);
|
||||
@@ -3,7 +3,6 @@ import { ChangeEvent, useState } from 'react';
|
||||
import { UseFormSetValue, useForm } from 'react-hook-form';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { Button, Input, Switch, Field, Label, TextArea, Stack, Alert, Box } from '@grafana/ui';
|
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
|
||||
@@ -52,8 +51,17 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
||||
const onSave = async (overwrite: boolean) => {
|
||||
const data = getValues();
|
||||
|
||||
const dashboardToSave: Dashboard = getSaveAsDashboardSaveModel(changedSaveModel, data, changeInfo.isNew);
|
||||
const result = await onSaveDashboard(dashboard, dashboardToSave, { overwrite, folderUid: data.folder.uid });
|
||||
const result = await onSaveDashboard(dashboard, {
|
||||
overwrite,
|
||||
folderUid: data.folder.uid,
|
||||
|
||||
// save as config
|
||||
saveAsCopy: true,
|
||||
isNew: changeInfo.isNew,
|
||||
copyTags: data.copyTags,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
});
|
||||
|
||||
if (result.status === 'success') {
|
||||
dashboard.closeModal();
|
||||
@@ -98,11 +106,7 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(() => onSave(false))}>
|
||||
<Field
|
||||
label={<TitleFieldLabel dashboard={changedSaveModel} onChange={setValue} />}
|
||||
invalid={!!errors.title}
|
||||
error={errors.title?.message}
|
||||
>
|
||||
<Field label={<TitleFieldLabel onChange={setValue} />} invalid={!!errors.title} error={errors.title?.message}>
|
||||
<Input
|
||||
{...register('title', { required: 'Required', validate: validateDashboardName })}
|
||||
aria-label="Save dashboard title field"
|
||||
@@ -113,7 +117,7 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={<DescriptionLabel dashboard={changedSaveModel} onChange={setValue} />}
|
||||
label={<DescriptionLabel onChange={setValue} />}
|
||||
invalid={!!errors.description}
|
||||
error={errors.description?.message}
|
||||
>
|
||||
@@ -149,7 +153,6 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
||||
}
|
||||
|
||||
export interface TitleLabelProps {
|
||||
dashboard: Dashboard;
|
||||
onChange: UseFormSetValue<SaveDashboardAsFormDTO>;
|
||||
}
|
||||
|
||||
@@ -168,7 +171,6 @@ export function TitleFieldLabel(props: TitleLabelProps) {
|
||||
}
|
||||
|
||||
export interface DescriptionLabelProps {
|
||||
dashboard: Dashboard;
|
||||
onChange: UseFormSetValue<SaveDashboardAsFormDTO>;
|
||||
}
|
||||
|
||||
@@ -198,15 +200,3 @@ async function validateDashboardName(title: string, formValues: SaveDashboardAsF
|
||||
return e instanceof Error ? e.message : 'Dashboard name is invalid';
|
||||
}
|
||||
}
|
||||
|
||||
function getSaveAsDashboardSaveModel(source: Dashboard, form: SaveDashboardAsFormDTO, isNew?: boolean): Dashboard {
|
||||
// TODO remove old alerts and thresholds when copying (See getSaveAsDashboardClone)
|
||||
return {
|
||||
...source,
|
||||
id: null,
|
||||
uid: '',
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
tags: isNew || form.copyTags ? source.tags : [],
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { SaveDashboardAsForm } from './SaveDashboardAsForm';
|
||||
import { SaveDashboardForm } from './SaveDashboardForm';
|
||||
import { SaveProvisionedDashboard } from './SaveProvisionedDashboard';
|
||||
import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm';
|
||||
import { getDashboardChangesFromScene } from './getDashboardChangesFromScene';
|
||||
|
||||
interface SaveDashboardDrawerState extends SceneObjectState {
|
||||
dashboardRef: SceneObjectRef<DashboardScene>;
|
||||
@@ -41,12 +40,11 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
|
||||
|
||||
static Component = ({ model }: SceneComponentProps<SaveDashboardDrawer>) => {
|
||||
const { saveProvisioned, showDiff, saveAsCopy, saveTimeRange, saveVariables, saveRefresh } = model.useState();
|
||||
const changeInfo = getDashboardChangesFromScene(
|
||||
model.state.dashboardRef.resolve(),
|
||||
saveTimeRange,
|
||||
saveVariables,
|
||||
saveRefresh
|
||||
);
|
||||
|
||||
const changeInfo = model.state.dashboardRef
|
||||
.resolve()
|
||||
.getDashboardChanges(saveTimeRange, saveVariables, saveRefresh);
|
||||
|
||||
const { changedSaveModel, initialSaveModel, diffs, diffCount, hasFolderChanges } = changeInfo;
|
||||
const changesCount = diffCount + (hasFolderChanges ? 1 : 0);
|
||||
const dashboard = model.state.dashboardRef.resolve();
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface Props {
|
||||
}
|
||||
|
||||
export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) {
|
||||
const { changedSaveModel, hasChanges } = changeInfo;
|
||||
const { hasChanges } = changeInfo;
|
||||
|
||||
const { state, onSaveDashboard } = useSaveDashboard(false);
|
||||
const [options, setOptions] = useState<SaveDashboardOptions>({
|
||||
@@ -32,7 +32,7 @@ export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) {
|
||||
});
|
||||
|
||||
const onSave = async (overwrite: boolean) => {
|
||||
const result = await onSaveDashboard(dashboard, changedSaveModel, { ...options, overwrite });
|
||||
const result = await onSaveDashboard(dashboard, { ...options, overwrite });
|
||||
if (result.status === 'success') {
|
||||
dashboard.closeModal();
|
||||
drawer.state.onSaveSuccess?.();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AdHocVariableModel } from '@grafana/data';
|
||||
import { Dashboard, Panel } from '@grafana/schema';
|
||||
|
||||
import { adHocVariableFiltersEqual, getDashboardChanges, getPanelChanges } from './getDashboardChanges';
|
||||
import { adHocVariableFiltersEqual, getRawDashboardChanges, getPanelChanges } from './getDashboardChanges';
|
||||
|
||||
describe('adHocVariableFiltersEqual', () => {
|
||||
it('should compare empty filters', () => {
|
||||
@@ -134,7 +134,7 @@ describe('getDashboardChanges', () => {
|
||||
hasRefreshChange: false,
|
||||
};
|
||||
|
||||
const result = getDashboardChanges(initial, changed, false, false, false);
|
||||
const result = getRawDashboardChanges(initial, changed, false, false, false);
|
||||
|
||||
expect(result).toEqual(expectedChanges);
|
||||
});
|
||||
@@ -165,7 +165,7 @@ describe('getDashboardChanges', () => {
|
||||
hasRefreshChange: false,
|
||||
};
|
||||
|
||||
const result = getDashboardChanges(newDashInitial, changed, false, false, false);
|
||||
const result = getRawDashboardChanges(newDashInitial, changed, false, false, false);
|
||||
|
||||
expect(result).toEqual(expectedChanges);
|
||||
});
|
||||
@@ -195,7 +195,7 @@ describe('getDashboardChanges', () => {
|
||||
hasRefreshChange: false,
|
||||
};
|
||||
|
||||
const result = getDashboardChanges(initial, changed, false, false, false);
|
||||
const result = getRawDashboardChanges(initial, changed, false, false, false);
|
||||
|
||||
expect(result).toEqual(expectedChanges);
|
||||
});
|
||||
@@ -236,7 +236,7 @@ describe('getDashboardChanges', () => {
|
||||
hasRefreshChange: false,
|
||||
};
|
||||
|
||||
const result = getDashboardChanges(initial, changed, true, false, false);
|
||||
const result = getRawDashboardChanges(initial, changed, true, false, false);
|
||||
|
||||
expect(result).toEqual(expectedChanges);
|
||||
});
|
||||
@@ -263,7 +263,7 @@ describe('getDashboardChanges', () => {
|
||||
hasRefreshChange: true,
|
||||
};
|
||||
|
||||
const result = getDashboardChanges(initial, changed, false, false, false);
|
||||
const result = getRawDashboardChanges(initial, changed, false, false, false);
|
||||
|
||||
expect(result).toEqual(expectedChanges);
|
||||
});
|
||||
@@ -301,7 +301,7 @@ describe('getDashboardChanges', () => {
|
||||
hasRefreshChange: true,
|
||||
};
|
||||
|
||||
const result = getDashboardChanges(initial, changed, false, false, true);
|
||||
const result = getRawDashboardChanges(initial, changed, false, false, true);
|
||||
|
||||
expect(result).toEqual(expectedChanges);
|
||||
});
|
||||
@@ -341,7 +341,7 @@ describe('getDashboardChanges', () => {
|
||||
hasRefreshChange: false,
|
||||
};
|
||||
|
||||
const result = getDashboardChanges(initial, changed, false, false, false);
|
||||
const result = getRawDashboardChanges(initial, changed, false, false, false);
|
||||
|
||||
expect(result).toEqual(expectedChanges);
|
||||
});
|
||||
@@ -392,7 +392,7 @@ describe('getDashboardChanges', () => {
|
||||
hasRefreshChange: false,
|
||||
};
|
||||
|
||||
const result = getDashboardChanges(initial, changed, false, true, false);
|
||||
const result = getRawDashboardChanges(initial, changed, false, true, false);
|
||||
|
||||
expect(result).toEqual(expectedChanges);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @ts-ignore
|
||||
import jsonMap from 'json-source-map';
|
||||
|
||||
import type { AdHocVariableModel, TypedVariableModel } from '@grafana/data';
|
||||
import { Dashboard, Panel, VariableOption } from '@grafana/schema';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
|
||||
import { jsonDiff } from '../settings/version-history/utils';
|
||||
|
||||
@@ -30,7 +30,28 @@ export function isEqual(a: VariableOption | undefined, b: VariableOption | undef
|
||||
return a === b || (a && b && a.selected === b.selected && deepEqual(a.text, b.text) && deepEqual(a.value, b.value));
|
||||
}
|
||||
|
||||
export function getDashboardChanges(
|
||||
// TODO[schema v2]
|
||||
export function getRawDashboardV2Changes(
|
||||
initial: DashboardV2Spec,
|
||||
changed: DashboardV2Spec,
|
||||
saveTimeRange?: boolean,
|
||||
saveVariables?: boolean,
|
||||
saveRefresh?: boolean
|
||||
) {
|
||||
return {
|
||||
changedSaveModel: changed,
|
||||
initialSaveModel: initial,
|
||||
diffs: jsonDiff(initial, changed),
|
||||
diffCount: 0,
|
||||
hasChanges: false,
|
||||
hasTimeChanges: false,
|
||||
isNew: false,
|
||||
hasVariableValueChanges: false,
|
||||
hasRefreshChange: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRawDashboardChanges(
|
||||
initial: Dashboard,
|
||||
changed: Dashboard,
|
||||
saveTimeRange?: boolean,
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
AdHocFiltersVariable,
|
||||
GroupByVariable,
|
||||
MultiValueVariable,
|
||||
sceneGraph,
|
||||
SceneRefreshPicker,
|
||||
} from '@grafana/scenes';
|
||||
import { VariableModel } from '@grafana/schema';
|
||||
|
||||
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
|
||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
|
||||
import { findVizPanelByKey } from '../utils/utils';
|
||||
|
||||
import { getDashboardChangesFromScene } from './getDashboardChangesFromScene';
|
||||
|
||||
describe('getDashboardChangesFromScene', () => {
|
||||
it('Can detect no changes', () => {
|
||||
const dashboard = setup();
|
||||
const result = getDashboardChangesFromScene(dashboard, false);
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.diffCount).toBe(0);
|
||||
});
|
||||
|
||||
it('Can detect time changed', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' });
|
||||
|
||||
const result = getDashboardChangesFromScene(dashboard, false);
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.diffCount).toBe(0);
|
||||
expect(result.hasTimeChanges).toBe(true);
|
||||
});
|
||||
|
||||
it('Can save time change', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' });
|
||||
|
||||
const result = getDashboardChangesFromScene(dashboard, true);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(1);
|
||||
});
|
||||
|
||||
it('Can detect folder change', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
dashboard.state.meta.folderUid = 'folder-2';
|
||||
|
||||
const result = getDashboardChangesFromScene(dashboard, false);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(0); // Diff count is 0 because the diff contemplate only the model
|
||||
expect(result.hasFolderChanges).toBe(true);
|
||||
});
|
||||
|
||||
it('Can detect refresh changed', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker);
|
||||
if (refreshPicker instanceof SceneRefreshPicker) {
|
||||
refreshPicker.setState({ refresh: '5s' });
|
||||
}
|
||||
|
||||
const result = getDashboardChangesFromScene(dashboard, false, false, false);
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.diffCount).toBe(0);
|
||||
expect(result.hasRefreshChange).toBe(true);
|
||||
});
|
||||
|
||||
it('Can save refresh change', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker);
|
||||
if (refreshPicker instanceof SceneRefreshPicker) {
|
||||
refreshPicker.setState({ refresh: '5s' });
|
||||
}
|
||||
|
||||
const result = getDashboardChangesFromScene(dashboard, false, false, true);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(1);
|
||||
});
|
||||
|
||||
describe('variable changes', () => {
|
||||
it('Can detect variable change', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
|
||||
appVar.changeValueTo('app2');
|
||||
|
||||
const result = getDashboardChangesFromScene(dashboard, false, false);
|
||||
|
||||
expect(result.hasVariableValueChanges).toBe(true);
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.diffCount).toBe(0);
|
||||
});
|
||||
|
||||
it('Can save variable value change', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
|
||||
appVar.changeValueTo('app2');
|
||||
|
||||
const result = getDashboardChangesFromScene(dashboard, false, true);
|
||||
|
||||
expect(result.hasVariableValueChanges).toBe(true);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(2);
|
||||
});
|
||||
|
||||
describe('Experimental variables', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.groupByVariable = true;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
config.featureToggles.groupByVariable = false;
|
||||
});
|
||||
|
||||
it('Can detect group by static options change', () => {
|
||||
const dashboard = transformSaveModelToScene({
|
||||
dashboard: {
|
||||
title: 'hello',
|
||||
uid: 'my-uid',
|
||||
schemaVersion: 30,
|
||||
panels: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Panel 1',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
version: 10,
|
||||
templating: {
|
||||
list: [
|
||||
{
|
||||
type: 'groupby',
|
||||
datasource: {
|
||||
type: 'ds',
|
||||
uid: 'ds-uid',
|
||||
},
|
||||
name: 'GroupBy',
|
||||
options: [
|
||||
{
|
||||
text: 'Host',
|
||||
value: 'host',
|
||||
},
|
||||
{
|
||||
text: 'Region',
|
||||
value: 'region',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
meta: {},
|
||||
});
|
||||
const initialSaveModel = transformSceneToSaveModel(dashboard);
|
||||
dashboard.setInitialSaveModel(initialSaveModel);
|
||||
|
||||
const variable = sceneGraph.lookupVariable('GroupBy', dashboard) as GroupByVariable;
|
||||
variable.setState({ defaultOptions: [{ text: 'Host', value: 'host' }] });
|
||||
const result = getDashboardChangesFromScene(dashboard, false, true);
|
||||
|
||||
expect(result.hasVariableValueChanges).toBe(false);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(1);
|
||||
});
|
||||
|
||||
it('Can detect adhoc filter static options change', () => {
|
||||
const adhocVar = {
|
||||
id: 'adhoc',
|
||||
name: 'adhoc',
|
||||
label: 'Adhoc Label',
|
||||
description: 'Adhoc Description',
|
||||
type: 'adhoc',
|
||||
datasource: {
|
||||
uid: 'gdev-prometheus',
|
||||
type: 'prometheus',
|
||||
},
|
||||
filters: [],
|
||||
baseFilters: [],
|
||||
defaultKeys: [
|
||||
{
|
||||
text: 'Host',
|
||||
value: 'host',
|
||||
},
|
||||
{
|
||||
text: 'Region',
|
||||
value: 'region',
|
||||
},
|
||||
],
|
||||
} as VariableModel;
|
||||
|
||||
const dashboard = transformSaveModelToScene({
|
||||
dashboard: {
|
||||
title: 'hello',
|
||||
uid: 'my-uid',
|
||||
schemaVersion: 30,
|
||||
panels: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Panel 1',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
version: 10,
|
||||
templating: {
|
||||
list: [adhocVar],
|
||||
},
|
||||
},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const initialSaveModel = transformSceneToSaveModel(dashboard);
|
||||
dashboard.setInitialSaveModel(initialSaveModel);
|
||||
|
||||
const variable = sceneGraph.lookupVariable('adhoc', dashboard) as AdHocFiltersVariable;
|
||||
variable.setState({ defaultKeys: [{ text: 'Host', value: 'host' }] });
|
||||
const result = getDashboardChangesFromScene(dashboard, false, false);
|
||||
|
||||
expect(result.hasVariableValueChanges).toBe(false);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Saving from panel edit', () => {
|
||||
it('Should commit panel edit changes', () => {
|
||||
const dashboard = setup();
|
||||
const panel = findVizPanelByKey(dashboard, 'panel-1')!;
|
||||
const editScene = buildPanelEditScene(panel);
|
||||
|
||||
dashboard.onEnterEditMode();
|
||||
dashboard.setState({ editPanel: editScene });
|
||||
|
||||
editScene.state.panelRef.resolve().setState({ title: 'changed title' });
|
||||
|
||||
const result = getDashboardChangesFromScene(dashboard, false, true);
|
||||
const panelSaveModel = result.changedSaveModel.panels![0];
|
||||
expect(panelSaveModel.title).toBe('changed title');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface ScenarioOptions {
|
||||
fromPanelEdit?: boolean;
|
||||
}
|
||||
|
||||
function setup(options: ScenarioOptions = {}) {
|
||||
const dashboard = transformSaveModelToScene({
|
||||
dashboard: {
|
||||
title: 'hello',
|
||||
uid: 'my-uid',
|
||||
schemaVersion: 30,
|
||||
panels: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Panel 1',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
version: 10,
|
||||
templating: {
|
||||
list: [
|
||||
{
|
||||
name: 'app',
|
||||
type: 'custom',
|
||||
current: {
|
||||
text: 'app1',
|
||||
value: 'app1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const initialSaveModel = transformSceneToSaveModel(dashboard);
|
||||
dashboard.setInitialSaveModel(initialSaveModel);
|
||||
|
||||
return dashboard;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
|
||||
|
||||
import { getDashboardChanges as getDashboardSaveModelChanges } from './getDashboardChanges';
|
||||
|
||||
/**
|
||||
* Get changes between the initial save model and the current scene.
|
||||
* It also checks if the folder has changed.
|
||||
* @param scene DashboardScene object
|
||||
* @param saveTimeRange if true, compare the time range
|
||||
* @param saveVariables if true, compare the variables
|
||||
* @param saveRefresh if true, compare the refresh interval
|
||||
* @returns
|
||||
*/
|
||||
export function getDashboardChangesFromScene(
|
||||
scene: DashboardScene,
|
||||
saveTimeRange?: boolean,
|
||||
saveVariables?: boolean,
|
||||
saveRefresh?: boolean
|
||||
) {
|
||||
const changeInfo = getDashboardSaveModelChanges(
|
||||
scene.getInitialSaveModel()!,
|
||||
transformSceneToSaveModel(scene),
|
||||
saveTimeRange,
|
||||
saveVariables,
|
||||
saveRefresh
|
||||
);
|
||||
const hasFolderChanges = scene.getInitialState()?.meta.folderUid !== scene.state.meta.folderUid;
|
||||
|
||||
return {
|
||||
...changeInfo,
|
||||
hasFolderChanges,
|
||||
hasChanges: changeInfo.hasChanges || hasFolderChanges,
|
||||
};
|
||||
}
|
||||
@@ -3,14 +3,15 @@ import * as React from 'react';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config, isFetchError } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import { Alert, Box, Button, Stack } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { Diffs } from '../settings/version-history/utils';
|
||||
|
||||
export interface DashboardChangeInfo {
|
||||
changedSaveModel: Dashboard;
|
||||
initialSaveModel: Dashboard;
|
||||
changedSaveModel: Dashboard | DashboardV2Spec;
|
||||
initialSaveModel: Dashboard | DashboardV2Spec;
|
||||
diffs: Diffs;
|
||||
diffCount: number;
|
||||
hasChanges: boolean;
|
||||
|
||||
@@ -7,7 +7,7 @@ import appEvents from 'app/core/app_events';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { updateDashboardName } from 'app/core/reducers/navBarTree';
|
||||
import { useSaveDashboardMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
import { SaveDashboardOptions } from 'app/features/dashboard/components/SaveDashboard/types';
|
||||
import { SaveDashboardAsOptions, SaveDashboardOptions } from 'app/features/dashboard/components/SaveDashboard/types';
|
||||
import { useDispatch } from 'app/types';
|
||||
import { DashboardSavedEvent } from 'app/types/events';
|
||||
|
||||
@@ -20,8 +20,26 @@ export function useSaveDashboard(isCopy = false) {
|
||||
const [saveDashboardRtkQuery] = useSaveDashboardMutation();
|
||||
|
||||
const [state, onSaveDashboard] = useAsyncFn(
|
||||
async (scene: DashboardScene, saveModel: Dashboard, options: SaveDashboardOptions) => {
|
||||
async (
|
||||
scene: DashboardScene,
|
||||
options: SaveDashboardOptions &
|
||||
SaveDashboardAsOptions & {
|
||||
// When provided, will take precedence over the scene's save model
|
||||
rawDashboardJSON?: Dashboard;
|
||||
}
|
||||
) => {
|
||||
{
|
||||
let saveModel = options.rawDashboardJSON ?? scene.getSaveModel();
|
||||
|
||||
if (options.saveAsCopy) {
|
||||
saveModel = scene.getSaveAsModel({
|
||||
isNew: options.isNew,
|
||||
title: options.title,
|
||||
description: options.description,
|
||||
copyTags: options.copyTags,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await saveDashboardRtkQuery({
|
||||
dashboard: saveModel,
|
||||
folderUid: options.folderUid,
|
||||
|
||||
@@ -37,20 +37,6 @@ import { RowActions } from './row-actions/RowActions';
|
||||
|
||||
jest.mock('../settings/version-history/HistorySrv');
|
||||
jest.mock('../serialization/transformSaveModelToScene');
|
||||
jest.mock('../saving/getDashboardChangesFromScene', () => ({
|
||||
// It compares the initial and changed save models and returns the differences
|
||||
// By default we assume there are differences to have the dirty state test logic tested
|
||||
getDashboardChangesFromScene: jest.fn(() => ({
|
||||
changedSaveModel: {},
|
||||
initialSaveModel: {},
|
||||
diffs: [],
|
||||
diffCount: 0,
|
||||
hasChanges: true,
|
||||
hasTimeChanges: false,
|
||||
isNew: false,
|
||||
hasVariableValueChanges: false,
|
||||
})),
|
||||
}));
|
||||
jest.mock('../serialization/transformSceneToSaveModel');
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
|
||||
@@ -25,11 +25,13 @@ import {
|
||||
VizPanel,
|
||||
} from '@grafana/scenes';
|
||||
import { Dashboard, DashboardLink, LibraryPanel } from '@grafana/schema';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { ScrollRefElement } from 'app/core/components/NativeScrollbar';
|
||||
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import store from 'app/core/store';
|
||||
import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||
@@ -43,6 +45,8 @@ import { DashboardEditPane } from '../edit-pane/DashboardEditPane';
|
||||
import { PanelEditor } from '../panel-edit/PanelEditor';
|
||||
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
|
||||
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
|
||||
import { DashboardChangeInfo } from '../saving/shared';
|
||||
import { DashboardSceneSerializerLike, getDashboardSceneSerializer } from '../serialization/DashboardSceneSerializer';
|
||||
import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import { gridItemToPanel } from '../serialization/transformSceneToSaveModel';
|
||||
import { DecoratedRevisionModel } from '../settings/VersionsEditView';
|
||||
@@ -149,10 +153,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
* State before editing started
|
||||
*/
|
||||
private _initialState?: DashboardSceneState;
|
||||
/**
|
||||
* The save model which the scene was originally created from
|
||||
*/
|
||||
private _initialSaveModel?: Dashboard;
|
||||
/**
|
||||
* Url state before editing started
|
||||
*/
|
||||
@@ -172,6 +172,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
private _scrollRef?: ScrollRefElement;
|
||||
private _prevScrollPos?: number;
|
||||
|
||||
// TODO: use feature toggle to allow v2 serializer
|
||||
private _serializer: DashboardSceneSerializerLike<Dashboard | DashboardV2Spec> = getDashboardSceneSerializer(true);
|
||||
|
||||
public constructor(state: Partial<DashboardSceneState>) {
|
||||
super({
|
||||
title: 'Dashboard',
|
||||
@@ -261,13 +264,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
this._changeTracker.startTrackingChanges();
|
||||
};
|
||||
|
||||
public saveCompleted(saveModel: Dashboard, result: SaveDashboardResponseDTO, folderUid?: string) {
|
||||
this._initialSaveModel = {
|
||||
...saveModel,
|
||||
id: result.id,
|
||||
uid: result.uid,
|
||||
version: result.version,
|
||||
};
|
||||
public saveCompleted(saveModel: Dashboard | DashboardV2Spec, result: SaveDashboardResponseDTO, folderUid?: string) {
|
||||
this._serializer.onSaveComplete(saveModel, result);
|
||||
|
||||
this._changeTracker.stopTrackingChanges();
|
||||
|
||||
@@ -649,12 +647,16 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
}
|
||||
|
||||
public getInitialSaveModel() {
|
||||
return this._initialSaveModel;
|
||||
return this._serializer.initialSaveModel;
|
||||
}
|
||||
|
||||
/** Hacky temp function until we refactor transformSaveModelToScene a bit */
|
||||
public setInitialSaveModel(saveModel: Dashboard) {
|
||||
this._initialSaveModel = saveModel;
|
||||
public setInitialSaveModel(saveModel?: Dashboard | DashboardV2Spec) {
|
||||
this._serializer.initialSaveModel = saveModel;
|
||||
}
|
||||
|
||||
public getTrackingInformation() {
|
||||
return this._serializer.getTrackingInformation();
|
||||
}
|
||||
|
||||
public async onDashboardDelete() {
|
||||
@@ -697,6 +699,18 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
this._scrollRef?.scrollTo(0, this._prevScrollPos!);
|
||||
}
|
||||
}
|
||||
|
||||
getSaveModel(): Dashboard | DashboardV2Spec {
|
||||
return this._serializer.getSaveModel(this);
|
||||
}
|
||||
|
||||
getSaveAsModel(options: SaveDashboardAsOptions): Dashboard | DashboardV2Spec {
|
||||
return this._serializer.getSaveAsModel(this, options);
|
||||
}
|
||||
|
||||
getDashboardChanges(saveTimeRange?: boolean, saveVariables?: boolean, saveRefresh?: boolean): DashboardChangeInfo {
|
||||
return this._serializer.getDashboardChangesFromScene(this, { saveTimeRange, saveVariables, saveRefresh });
|
||||
}
|
||||
}
|
||||
|
||||
export class DashboardVariableDependency implements SceneVariableDependencyConfigLike {
|
||||
@@ -760,3 +774,7 @@ export class DashboardVariableDependency implements SceneVariableDependencyConfi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isV2Dashboard(model: Dashboard | DashboardV2Spec): model is DashboardV2Spec {
|
||||
return 'elements' in model;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ import { DashboardInteractions } from '../utils/interactions';
|
||||
import { DynamicDashNavButtonModel, dynamicDashNavActions } from '../utils/registerDynamicDashNavAction';
|
||||
import { isLibraryPanel } from '../utils/utils';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { DashboardScene, isV2Dashboard } from './DashboardScene';
|
||||
import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton';
|
||||
|
||||
interface Props {
|
||||
@@ -151,12 +151,17 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
toolbarActions.push({
|
||||
group: 'icon-actions',
|
||||
condition: meta.isSnapshot && !meta.dashboardNotFound && !isEditing,
|
||||
render: () => (
|
||||
<GoToSnapshotOriginButton
|
||||
key="go-to-snapshot-origin"
|
||||
originalURL={dashboard.getInitialSaveModel()?.snapshot?.originalUrl ?? ''}
|
||||
/>
|
||||
),
|
||||
render: () => {
|
||||
const saveModel = dashboard.getInitialSaveModel();
|
||||
|
||||
if (saveModel && isV2Dashboard(saveModel)) {
|
||||
throw new Error('v2 schema not implemented');
|
||||
}
|
||||
|
||||
return (
|
||||
<GoToSnapshotOriginButton key="go-to-snapshot-origin" originalURL={saveModel?.snapshot?.originalUrl ?? ''} />
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (!isEditingPanel && !isEditing) {
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
AdHocFiltersVariable,
|
||||
GroupByVariable,
|
||||
MultiValueVariable,
|
||||
sceneGraph,
|
||||
SceneRefreshPicker,
|
||||
} from '@grafana/scenes';
|
||||
import { Dashboard, VariableModel } from '@grafana/schema';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
|
||||
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
|
||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
|
||||
import { findVizPanelByKey } from '../utils/utils';
|
||||
|
||||
import { V1DashboardSerializer, V2DashboardSerializer } from './DashboardSceneSerializer';
|
||||
|
||||
describe('DashboardSceneSerializer', () => {
|
||||
describe('v1 schema', () => {
|
||||
it('Can detect no changes', () => {
|
||||
const dashboard = setup();
|
||||
const result = dashboard.getDashboardChanges(false);
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.diffCount).toBe(0);
|
||||
});
|
||||
|
||||
it('Can detect time changed', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' });
|
||||
|
||||
const result = dashboard.getDashboardChanges(false);
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.diffCount).toBe(0);
|
||||
expect(result.hasTimeChanges).toBe(true);
|
||||
});
|
||||
|
||||
it('Can save time change', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' });
|
||||
|
||||
const result = dashboard.getDashboardChanges(true);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(1);
|
||||
});
|
||||
|
||||
it('Can detect folder change', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
dashboard.state.meta.folderUid = 'folder-2';
|
||||
|
||||
const result = dashboard.getDashboardChanges(false);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(0); // Diff count is 0 because the diff contemplate only the model
|
||||
expect(result.hasFolderChanges).toBe(true);
|
||||
});
|
||||
|
||||
it('Can detect refresh changed', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker);
|
||||
if (refreshPicker instanceof SceneRefreshPicker) {
|
||||
refreshPicker.setState({ refresh: '5s' });
|
||||
}
|
||||
|
||||
const result = dashboard.getDashboardChanges(false, false, false);
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.diffCount).toBe(0);
|
||||
expect(result.hasRefreshChange).toBe(true);
|
||||
});
|
||||
|
||||
it('Can save refresh change', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker);
|
||||
if (refreshPicker instanceof SceneRefreshPicker) {
|
||||
refreshPicker.setState({ refresh: '5s' });
|
||||
}
|
||||
|
||||
const result = dashboard.getDashboardChanges(false, false, true);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(1);
|
||||
});
|
||||
|
||||
describe('variable changes', () => {
|
||||
it('Can detect variable change', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
|
||||
appVar.changeValueTo('app2');
|
||||
|
||||
const result = dashboard.getDashboardChanges(false, false);
|
||||
|
||||
expect(result.hasVariableValueChanges).toBe(true);
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.diffCount).toBe(0);
|
||||
});
|
||||
|
||||
it('Can save variable value change', () => {
|
||||
const dashboard = setup();
|
||||
|
||||
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
|
||||
appVar.changeValueTo('app2');
|
||||
|
||||
const result = dashboard.getDashboardChanges(false, true);
|
||||
|
||||
expect(result.hasVariableValueChanges).toBe(true);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(2);
|
||||
});
|
||||
|
||||
describe('Experimental variables', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.groupByVariable = true;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
config.featureToggles.groupByVariable = false;
|
||||
});
|
||||
|
||||
it('Can detect group by static options change', () => {
|
||||
const dashboard = transformSaveModelToScene({
|
||||
dashboard: {
|
||||
title: 'hello',
|
||||
uid: 'my-uid',
|
||||
schemaVersion: 30,
|
||||
panels: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Panel 1',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
version: 10,
|
||||
templating: {
|
||||
list: [
|
||||
{
|
||||
type: 'groupby',
|
||||
datasource: {
|
||||
type: 'ds',
|
||||
uid: 'ds-uid',
|
||||
},
|
||||
name: 'GroupBy',
|
||||
options: [
|
||||
{
|
||||
text: 'Host',
|
||||
value: 'host',
|
||||
},
|
||||
{
|
||||
text: 'Region',
|
||||
value: 'region',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
meta: {},
|
||||
});
|
||||
const initialSaveModel = transformSceneToSaveModel(dashboard);
|
||||
dashboard.setInitialSaveModel(initialSaveModel);
|
||||
|
||||
const variable = sceneGraph.lookupVariable('GroupBy', dashboard) as GroupByVariable;
|
||||
variable.setState({ defaultOptions: [{ text: 'Host', value: 'host' }] });
|
||||
const result = dashboard.getDashboardChanges(false, true);
|
||||
|
||||
expect(result.hasVariableValueChanges).toBe(false);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(1);
|
||||
});
|
||||
|
||||
it('Can detect adhoc filter static options change', () => {
|
||||
const adhocVar = {
|
||||
id: 'adhoc',
|
||||
name: 'adhoc',
|
||||
label: 'Adhoc Label',
|
||||
description: 'Adhoc Description',
|
||||
type: 'adhoc',
|
||||
datasource: {
|
||||
uid: 'gdev-prometheus',
|
||||
type: 'prometheus',
|
||||
},
|
||||
filters: [],
|
||||
baseFilters: [],
|
||||
defaultKeys: [
|
||||
{
|
||||
text: 'Host',
|
||||
value: 'host',
|
||||
},
|
||||
{
|
||||
text: 'Region',
|
||||
value: 'region',
|
||||
},
|
||||
],
|
||||
} as VariableModel;
|
||||
|
||||
const dashboard = transformSaveModelToScene({
|
||||
dashboard: {
|
||||
title: 'hello',
|
||||
uid: 'my-uid',
|
||||
schemaVersion: 30,
|
||||
panels: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Panel 1',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
version: 10,
|
||||
templating: {
|
||||
list: [adhocVar],
|
||||
},
|
||||
},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const initialSaveModel = transformSceneToSaveModel(dashboard);
|
||||
dashboard.setInitialSaveModel(initialSaveModel);
|
||||
|
||||
const variable = sceneGraph.lookupVariable('adhoc', dashboard) as AdHocFiltersVariable;
|
||||
variable.setState({ defaultKeys: [{ text: 'Host', value: 'host' }] });
|
||||
const result = dashboard.getDashboardChanges(false, false);
|
||||
|
||||
expect(result.hasVariableValueChanges).toBe(false);
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.diffCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Saving from panel edit', () => {
|
||||
it('Should commit panel edit changes', () => {
|
||||
const dashboard = setup();
|
||||
const panel = findVizPanelByKey(dashboard, 'panel-1')!;
|
||||
const editScene = buildPanelEditScene(panel);
|
||||
|
||||
dashboard.onEnterEditMode();
|
||||
dashboard.setState({ editPanel: editScene });
|
||||
|
||||
editScene.state.panelRef.resolve().setState({ title: 'changed title' });
|
||||
|
||||
const result = dashboard.getDashboardChanges(false, true);
|
||||
const panelSaveModel = (result.changedSaveModel as Dashboard).panels![0];
|
||||
expect(panelSaveModel.title).toBe('changed title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tracking information', () => {
|
||||
it('provides dashboard tracking information with no initial save model', () => {
|
||||
const serializer = new V1DashboardSerializer();
|
||||
expect(serializer.getTrackingInformation()).toBe(undefined);
|
||||
});
|
||||
|
||||
it('provides dashboard tracking information with from initial save model', () => {
|
||||
const serializer = new V1DashboardSerializer();
|
||||
serializer.initialSaveModel = {
|
||||
schemaVersion: 30,
|
||||
version: 10,
|
||||
uid: 'my-uid',
|
||||
title: 'hello',
|
||||
liveNow: true,
|
||||
panels: [
|
||||
{
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
type: 'timeseries',
|
||||
},
|
||||
],
|
||||
|
||||
templating: {
|
||||
list: [
|
||||
{
|
||||
type: 'query',
|
||||
name: 'server',
|
||||
},
|
||||
{
|
||||
type: 'query',
|
||||
name: 'host',
|
||||
},
|
||||
{
|
||||
type: 'textbox',
|
||||
name: 'search',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(serializer.getTrackingInformation()).toEqual({
|
||||
uid: 'my-uid',
|
||||
title: 'hello',
|
||||
schemaVersion: 30,
|
||||
panels_count: 3,
|
||||
panel_type_text_count: 2,
|
||||
panel_type_timeseries_count: 1,
|
||||
variable_type_query_count: 2,
|
||||
variable_type_textbox_count: 1,
|
||||
settings_nowdelay: undefined,
|
||||
settings_livenow: true,
|
||||
version_before_migration: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('v2 schema', () => {
|
||||
it('should throw on getSaveAsModel', () => {
|
||||
const serializer = new V2DashboardSerializer();
|
||||
const dashboard = setup();
|
||||
expect(() => serializer.getSaveAsModel(dashboard, {})).toThrow('Method not implemented.');
|
||||
});
|
||||
|
||||
it('should throw on getDashboardChangesFromScene', () => {
|
||||
const serializer = new V2DashboardSerializer();
|
||||
const dashboard = setup();
|
||||
expect(() => serializer.getDashboardChangesFromScene(dashboard)).toThrow('Method not implemented.');
|
||||
});
|
||||
|
||||
it('should throw on onSaveComplete', () => {
|
||||
const serializer = new V2DashboardSerializer();
|
||||
|
||||
expect(() =>
|
||||
serializer.onSaveComplete({} as DashboardV2Spec, {
|
||||
id: 1,
|
||||
uid: 'aa',
|
||||
slug: 'slug',
|
||||
url: 'url',
|
||||
version: 2,
|
||||
status: 'status',
|
||||
})
|
||||
).toThrow('Method not implemented.');
|
||||
});
|
||||
|
||||
it('should throw on getDashboardChangesFromScene', () => {
|
||||
const serializer = new V2DashboardSerializer();
|
||||
expect(() => serializer.getTrackingInformation()).toThrow('Method not implemented.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface ScenarioOptions {
|
||||
fromPanelEdit?: boolean;
|
||||
}
|
||||
|
||||
function setup(options: ScenarioOptions = {}) {
|
||||
const dashboard = transformSaveModelToScene({
|
||||
dashboard: {
|
||||
title: 'hello',
|
||||
uid: 'my-uid',
|
||||
schemaVersion: 30,
|
||||
panels: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Panel 1',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
version: 10,
|
||||
templating: {
|
||||
list: [
|
||||
{
|
||||
name: 'app',
|
||||
type: 'custom',
|
||||
current: {
|
||||
text: 'app1',
|
||||
value: 'app1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const initialSaveModel = transformSceneToSaveModel(dashboard);
|
||||
dashboard.setInitialSaveModel(initialSaveModel);
|
||||
|
||||
return dashboard;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types';
|
||||
import { getV1SchemaPanelCounts, getV1SchemaVariables } from 'app/features/dashboard/utils/tracking';
|
||||
import { SaveDashboardResponseDTO } from 'app/types';
|
||||
|
||||
import { getRawDashboardChanges } from '../saving/getDashboardChanges';
|
||||
import { DashboardChangeInfo } from '../saving/shared';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
import { transformSceneToSaveModel } from './transformSceneToSaveModel';
|
||||
import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2';
|
||||
|
||||
export interface DashboardSceneSerializerLike<T> {
|
||||
/**
|
||||
* The save model which the dashboard scene was originally created from
|
||||
*/
|
||||
initialSaveModel?: T;
|
||||
getSaveModel: (s: DashboardScene) => T;
|
||||
getSaveAsModel: (s: DashboardScene, options: SaveDashboardAsOptions) => T;
|
||||
getDashboardChangesFromScene: (
|
||||
scene: DashboardScene,
|
||||
options: {
|
||||
saveTimeRange?: boolean;
|
||||
saveVariables?: boolean;
|
||||
saveRefresh?: boolean;
|
||||
}
|
||||
) => DashboardChangeInfo;
|
||||
onSaveComplete(saveModel: T, result: SaveDashboardResponseDTO): void;
|
||||
getTrackingInformation: () => DashboardTrackingInfo | undefined;
|
||||
}
|
||||
|
||||
interface DashboardTrackingInfo {
|
||||
uid?: string;
|
||||
title?: string;
|
||||
schemaVersion: number;
|
||||
version_before_migration?: number;
|
||||
panels_count: number;
|
||||
settings_nowdelay?: number;
|
||||
settings_livenow?: boolean;
|
||||
}
|
||||
|
||||
export class V1DashboardSerializer implements DashboardSceneSerializerLike<Dashboard> {
|
||||
initialSaveModel?: Dashboard;
|
||||
|
||||
getSaveModel(s: DashboardScene) {
|
||||
return transformSceneToSaveModel(s);
|
||||
}
|
||||
|
||||
getSaveAsModel(s: DashboardScene, options: SaveDashboardAsOptions) {
|
||||
const saveModel = this.getSaveModel(s);
|
||||
|
||||
return {
|
||||
...saveModel,
|
||||
id: null,
|
||||
uid: '',
|
||||
title: options.title || '',
|
||||
description: options.description || '',
|
||||
tags: options.isNew || options.copyTags ? saveModel.tags : [],
|
||||
};
|
||||
}
|
||||
|
||||
getDashboardChangesFromScene(
|
||||
scene: DashboardScene,
|
||||
options: { saveTimeRange?: boolean; saveVariables?: boolean; saveRefresh?: boolean }
|
||||
) {
|
||||
const changedSaveModel = this.getSaveModel(scene);
|
||||
const changeInfo = getRawDashboardChanges(
|
||||
this.initialSaveModel!,
|
||||
changedSaveModel,
|
||||
options.saveTimeRange,
|
||||
options.saveVariables,
|
||||
options.saveRefresh
|
||||
);
|
||||
|
||||
const hasFolderChanges = scene.getInitialState()?.meta.folderUid !== scene.state.meta.folderUid;
|
||||
|
||||
return {
|
||||
...changeInfo,
|
||||
hasFolderChanges,
|
||||
hasChanges: changeInfo.hasChanges || hasFolderChanges,
|
||||
};
|
||||
}
|
||||
|
||||
onSaveComplete(saveModel: Dashboard, result: SaveDashboardResponseDTO): void {
|
||||
this.initialSaveModel = {
|
||||
...saveModel,
|
||||
id: result.id,
|
||||
uid: result.uid,
|
||||
version: result.version,
|
||||
};
|
||||
}
|
||||
|
||||
getTrackingInformation(): DashboardTrackingInfo | undefined {
|
||||
const panels = getV1SchemaPanelCounts(this.initialSaveModel?.panels || []);
|
||||
const variables = getV1SchemaVariables(this.initialSaveModel?.templating?.list || []);
|
||||
|
||||
if (this.initialSaveModel) {
|
||||
return {
|
||||
uid: this.initialSaveModel.uid,
|
||||
title: this.initialSaveModel.title,
|
||||
schemaVersion: this.initialSaveModel.schemaVersion,
|
||||
version_before_migration: this.initialSaveModel.version,
|
||||
panels_count: this.initialSaveModel.panels?.length || 0,
|
||||
settings_nowdelay: undefined,
|
||||
settings_livenow: !!this.initialSaveModel.liveNow,
|
||||
...panels,
|
||||
...variables,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class V2DashboardSerializer implements DashboardSceneSerializerLike<DashboardV2Spec> {
|
||||
initialSaveModel?: DashboardV2Spec;
|
||||
|
||||
getSaveModel(s: DashboardScene) {
|
||||
return transformSceneToSaveModelSchemaV2(s);
|
||||
}
|
||||
|
||||
getSaveAsModel(s: DashboardScene, options: SaveDashboardAsOptions) {
|
||||
throw new Error('Method not implemented.');
|
||||
// eslint-disable-next-line
|
||||
return {} as DashboardV2Spec;
|
||||
}
|
||||
|
||||
getDashboardChangesFromScene(scene: DashboardScene) {
|
||||
throw new Error('v2 schema: Method not implemented.');
|
||||
// eslint-disable-next-line
|
||||
return {} as DashboardChangeInfo;
|
||||
}
|
||||
|
||||
onSaveComplete(saveModel: DashboardV2Spec, result: SaveDashboardResponseDTO): void {
|
||||
throw new Error('v2 schema: Method not implemented.');
|
||||
}
|
||||
|
||||
getTrackingInformation() {
|
||||
throw new Error('v2 schema: Method not implemented.');
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDashboardSceneSerializer(
|
||||
forceLegacy?: boolean
|
||||
): DashboardSceneSerializerLike<Dashboard | DashboardV2Spec> {
|
||||
// When we have end-to-end v2 API integration, this will be controlled by a feature toggle, no need for forceLegacy
|
||||
if (forceLegacy) {
|
||||
return new V1DashboardSerializer();
|
||||
}
|
||||
|
||||
if (config.featureToggles.dashboardSchemaV2) {
|
||||
return new V2DashboardSerializer();
|
||||
}
|
||||
|
||||
return new V1DashboardSerializer();
|
||||
}
|
||||
+1
-1
@@ -57,7 +57,7 @@ type DeepPartial<T> = T extends object
|
||||
}
|
||||
: T;
|
||||
|
||||
export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnapshot = false): Partial<DashboardV2Spec> {
|
||||
export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnapshot = false): DashboardV2Spec {
|
||||
const oldDash = scene.state;
|
||||
const timeRange = oldDash.$timeRange!.state;
|
||||
|
||||
|
||||
@@ -90,9 +90,10 @@ export class JsonModelEditView extends SceneObjectBase<JsonModelEditViewState> i
|
||||
const { jsonText } = model.useState();
|
||||
|
||||
const onSave = async (overwrite: boolean) => {
|
||||
const result = await onSaveDashboard(dashboard, JSON.parse(model.state.jsonText), {
|
||||
const result = await onSaveDashboard(dashboard, {
|
||||
folderUid: dashboard.state.meta.folderUid,
|
||||
overwrite,
|
||||
rawDashboardJSON: JSON.parse(model.state.jsonText),
|
||||
});
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
|
||||
import { ObjectMeta } from 'app/features/apiserver/types';
|
||||
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||
import { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils';
|
||||
@@ -18,8 +19,16 @@ export interface SaveDashboardOptions extends CloneOptions {
|
||||
makeEditable?: boolean;
|
||||
}
|
||||
|
||||
export interface SaveDashboardAsOptions {
|
||||
saveAsCopy?: boolean;
|
||||
isNew?: boolean;
|
||||
copyTags?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SaveDashboardCommand {
|
||||
dashboard: Dashboard;
|
||||
dashboard: Dashboard | DashboardV2Spec;
|
||||
message?: string;
|
||||
folderUid?: string;
|
||||
overwrite?: boolean;
|
||||
|
||||
@@ -7,9 +7,9 @@ import { PanelModel } from '../state/PanelModel';
|
||||
|
||||
export function trackDashboardLoaded(dashboard: DashboardModel, duration?: number, versionBeforeMigration?: number) {
|
||||
// Count the different types of variables
|
||||
const variables = getVariables(dashboard.templating.list);
|
||||
const variables = getV1SchemaVariables(dashboard.templating.list);
|
||||
// Count the different types of panels
|
||||
const panels = getPanelCounts(dashboard.panels);
|
||||
const panels = getV1SchemaPanelCounts(dashboard.panels);
|
||||
|
||||
DashboardInteractions.dashboardInitialized({
|
||||
uid: dashboard.uid,
|
||||
@@ -28,28 +28,17 @@ export function trackDashboardLoaded(dashboard: DashboardModel, duration?: numbe
|
||||
}
|
||||
|
||||
export function trackDashboardSceneLoaded(dashboard: DashboardScene, duration?: number) {
|
||||
const initialSaveModel = dashboard.getInitialSaveModel();
|
||||
if (initialSaveModel) {
|
||||
const panels = getPanelCounts(initialSaveModel.panels || []);
|
||||
const variables = getVariables(initialSaveModel.templating?.list || []);
|
||||
DashboardInteractions.dashboardInitialized({
|
||||
uid: initialSaveModel.uid,
|
||||
title: initialSaveModel.title,
|
||||
theme: undefined,
|
||||
schemaVersion: initialSaveModel.schemaVersion,
|
||||
version_before_migration: initialSaveModel.version,
|
||||
panels_count: initialSaveModel.panels?.length || 0,
|
||||
...panels,
|
||||
...variables,
|
||||
settings_nowdelay: undefined,
|
||||
settings_livenow: !!initialSaveModel.liveNow,
|
||||
duration,
|
||||
isScene: true,
|
||||
});
|
||||
}
|
||||
const trackingInformation = dashboard.getTrackingInformation();
|
||||
|
||||
DashboardInteractions.dashboardInitialized({
|
||||
theme: undefined,
|
||||
duration,
|
||||
isScene: true,
|
||||
...trackingInformation,
|
||||
});
|
||||
}
|
||||
|
||||
function getPanelCounts(panels: Panel[] | PanelModel[]) {
|
||||
export function getV1SchemaPanelCounts(panels: Panel[] | PanelModel[]) {
|
||||
return panels
|
||||
.map((p) => p.type)
|
||||
.reduce((r: Record<string, number>, p) => {
|
||||
@@ -58,7 +47,7 @@ function getPanelCounts(panels: Panel[] | PanelModel[]) {
|
||||
}, {});
|
||||
}
|
||||
|
||||
function getVariables(variableList: VariableModel[]) {
|
||||
export function getV1SchemaVariables(variableList: VariableModel[]) {
|
||||
return variableList
|
||||
.map((v) => v.type)
|
||||
.reduce((r: Record<string, number>, k) => {
|
||||
@@ -67,5 +56,5 @@ function getVariables(variableList: VariableModel[]) {
|
||||
}, {});
|
||||
}
|
||||
|
||||
const variableName = (type: string) => `variable_type_${type}_count`;
|
||||
export const variableName = (type: string) => `variable_type_${type}_count`;
|
||||
const panelName = (type: string) => `panel_type_${type}_count`;
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@grafana/data';
|
||||
|
||||
import { LogDetails, Props } from './LogDetails';
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
|
||||
import { createLogRow } from './__mocks__/logRow';
|
||||
import { getLogRowStyles } from './getLogRowStyles';
|
||||
|
||||
@@ -58,6 +59,33 @@ describe('LogDetails', () => {
|
||||
expect(screen.getByRole('cell', { name: 'key2' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: 'label2' })).toBeInTheDocument();
|
||||
});
|
||||
it('should show an option to display the log line when displayed fields are used', async () => {
|
||||
const onClickShowField = jest.fn();
|
||||
|
||||
setup({ displayedFields: ['key1'], onClickShowField }, { labels: { key1: 'label1' } });
|
||||
expect(screen.getByRole('cell', { name: 'key1' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Show log line')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Show log line'));
|
||||
|
||||
expect(onClickShowField).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should show an active option to display the log line when displayed fields are used', async () => {
|
||||
const onClickHideField = jest.fn();
|
||||
|
||||
setup({ displayedFields: ['key1', LOG_LINE_BODY_FIELD_NAME], onClickHideField }, { labels: { key1: 'label1' } });
|
||||
expect(screen.getByRole('cell', { name: 'key1' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Hide log line')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Hide log line'));
|
||||
|
||||
expect(onClickHideField).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should not show an option to display the log line when displayed fields are not used', () => {
|
||||
setup({ displayedFields: undefined }, { labels: { key1: 'label1' } });
|
||||
expect(screen.getByRole('cell', { name: 'key1' })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Show log line')).not.toBeInTheDocument();
|
||||
});
|
||||
it('should render filter controls when the callbacks are provided', () => {
|
||||
setup(
|
||||
{
|
||||
@@ -267,4 +295,89 @@ describe('LogDetails', () => {
|
||||
expect(screen.getByText('shouldShowLinkName')).toBeInTheDocument();
|
||||
expect(screen.getByText('shouldShowLinkValue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Label types', () => {
|
||||
const entry = 'test';
|
||||
const labels = {
|
||||
label1: 'value1',
|
||||
label2: 'value2',
|
||||
label3: 'value3',
|
||||
};
|
||||
const dataFrame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'timestamp', config: {}, type: FieldType.time, values: [1] },
|
||||
{ name: 'body', type: FieldType.string, values: [entry] },
|
||||
{ name: 'id', type: FieldType.string, values: ['1'] },
|
||||
{
|
||||
name: 'labels',
|
||||
type: FieldType.other,
|
||||
values: [labels],
|
||||
},
|
||||
{
|
||||
name: 'labelTypes',
|
||||
type: FieldType.other,
|
||||
values: [
|
||||
{
|
||||
label1: 'I',
|
||||
label2: 'S',
|
||||
label3: 'P',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
type: DataFrameType.LogLines,
|
||||
},
|
||||
});
|
||||
it('should show label types if they are available and supported', () => {
|
||||
setup(
|
||||
{},
|
||||
{
|
||||
entry,
|
||||
dataFrame,
|
||||
entryFieldIndex: 0,
|
||||
rowIndex: 0,
|
||||
labels,
|
||||
datasourceType: 'loki',
|
||||
rowId: '1',
|
||||
}
|
||||
);
|
||||
|
||||
// Show labels and links
|
||||
expect(screen.getByText('label1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
expect(screen.getByText('label2')).toBeInTheDocument();
|
||||
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||
expect(screen.getByText('label3')).toBeInTheDocument();
|
||||
expect(screen.getByText('value3')).toBeInTheDocument();
|
||||
expect(screen.getByText('I')).toBeInTheDocument();
|
||||
expect(screen.getByText('S')).toBeInTheDocument();
|
||||
expect(screen.getByText('P')).toBeInTheDocument();
|
||||
});
|
||||
it('should not show label types if they are unavailable or not supported', () => {
|
||||
setup(
|
||||
{},
|
||||
{
|
||||
entry,
|
||||
dataFrame,
|
||||
entryFieldIndex: 0,
|
||||
rowIndex: 0,
|
||||
labels,
|
||||
datasourceType: 'other datasource',
|
||||
rowId: '1',
|
||||
}
|
||||
);
|
||||
|
||||
// Show labels and links
|
||||
expect(screen.getByText('label1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
expect(screen.getByText('label2')).toBeInTheDocument();
|
||||
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||
expect(screen.getByText('label3')).toBeInTheDocument();
|
||||
expect(screen.getByText('value3')).toBeInTheDocument();
|
||||
expect(screen.queryByText('I')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('S')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('P')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,11 @@ import { PureComponent } from 'react';
|
||||
|
||||
import { CoreApp, DataFrame, DataFrameType, Field, LinkModel, LogRowModel } from '@grafana/data';
|
||||
import { PopoverContent, Themeable2, withTheme2 } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
import { calculateLogsLabelStats, calculateStats } from '../utils';
|
||||
|
||||
import { LogDetailsBody } from './LogDetailsBody';
|
||||
import { LogDetailsRow } from './LogDetailsRow';
|
||||
import { getLogLevelStyles, LogRowStyles } from './getLogRowStyles';
|
||||
import { getAllFields, createLogLineLinks } from './logParser';
|
||||
@@ -86,10 +88,28 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||
<div className={styles.logDetailsContainer}>
|
||||
<table className={styles.logDetailsTable}>
|
||||
<tbody>
|
||||
{displayedFields && displayedFields.length > 0 && (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={100} className={styles.logDetailsHeading} aria-label="Fields">
|
||||
<Trans i18nKey="logs.log-details.log-line">Log line</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
<LogDetailsBody
|
||||
onClickShowField={onClickShowField}
|
||||
onClickHideField={onClickHideField}
|
||||
row={row}
|
||||
app={app}
|
||||
displayedFields={displayedFields}
|
||||
disableActions={false}
|
||||
theme={theme}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(labelsAvailable || fieldsAvailable) && (
|
||||
<tr>
|
||||
<td colSpan={100} className={styles.logDetailsHeading} aria-label="Fields">
|
||||
Fields
|
||||
<Trans i18nKey="logs.log-details.fields">Fields</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -142,7 +162,7 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||
{fieldsWithLinksAvailable && (
|
||||
<tr>
|
||||
<td colSpan={100} className={styles.logDetailsHeading} aria-label="Data Links">
|
||||
Links
|
||||
<Trans i18nKey="logs.log-details.links">Links</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -192,7 +212,7 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||
{!fieldsAvailable && !labelsAvailable && !fieldsWithLinksAvailable && (
|
||||
<tr>
|
||||
<td colSpan={100} aria-label="No details">
|
||||
No details available
|
||||
<Trans i18nKey="logs.log-details.no-details">No details available</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { css } from '@emotion/css';
|
||||
import memoizeOne from 'memoize-one';
|
||||
|
||||
import { CoreApp, GrafanaTheme2, LogRowModel } from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { IconButton, Themeable2 } from '@grafana/ui';
|
||||
|
||||
import { getLogRowStyles } from './getLogRowStyles';
|
||||
|
||||
export interface Props extends Themeable2 {
|
||||
app?: CoreApp;
|
||||
disableActions: boolean;
|
||||
displayedFields?: string[];
|
||||
onClickShowField?: (key: string) => void;
|
||||
onClickHideField?: (key: string) => void;
|
||||
row: LogRowModel;
|
||||
theme: GrafanaTheme2;
|
||||
}
|
||||
|
||||
const getStyles = memoizeOne((theme: GrafanaTheme2) => {
|
||||
return {
|
||||
buttonRow: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing(0.5),
|
||||
marginLeft: theme.spacing(0.5),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
export const LOG_LINE_BODY_FIELD_NAME = '___LOG_LINE_BODY___';
|
||||
|
||||
export const LogDetailsBody = (props: Props) => {
|
||||
const showField = () => {
|
||||
const { onClickShowField, row } = props;
|
||||
if (onClickShowField) {
|
||||
onClickShowField(LOG_LINE_BODY_FIELD_NAME);
|
||||
}
|
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_show_body_clicked', {
|
||||
datasourceType: row.datasourceType,
|
||||
logRowUid: row.uid,
|
||||
type: 'enable',
|
||||
app: props.app,
|
||||
});
|
||||
};
|
||||
|
||||
const hideField = () => {
|
||||
const { onClickHideField, row } = props;
|
||||
if (onClickHideField) {
|
||||
onClickHideField(LOG_LINE_BODY_FIELD_NAME);
|
||||
}
|
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_show_body_clicked', {
|
||||
datasourceType: row.datasourceType,
|
||||
logRowUid: row.uid,
|
||||
type: 'disable',
|
||||
app: props.app,
|
||||
});
|
||||
};
|
||||
|
||||
const { theme, displayedFields, disableActions, row } = props;
|
||||
const styles = getStyles(theme);
|
||||
const rowStyles = getLogRowStyles(theme);
|
||||
|
||||
const toggleFieldButton =
|
||||
displayedFields != null && displayedFields.includes(LOG_LINE_BODY_FIELD_NAME) ? (
|
||||
<IconButton variant="primary" tooltip="Hide log line" name="eye" onClick={hideField} />
|
||||
) : (
|
||||
<IconButton tooltip="Show log line" name="eye" onClick={showField} />
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className={rowStyles.logDetailsValue}>
|
||||
<td className={rowStyles.logsDetailsIcon}>
|
||||
<div className={styles.buttonRow}>{!disableActions && displayedFields && toggleFieldButton}</div>
|
||||
</td>
|
||||
|
||||
<td className={rowStyles.logDetailsLabel} colSpan={100}>
|
||||
{row.entry}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -15,9 +15,18 @@ import {
|
||||
LogRowModel,
|
||||
} from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { ClipboardButton, DataLinkButton, IconButton, PopoverContent, Themeable2, withTheme2 } from '@grafana/ui';
|
||||
import {
|
||||
ClipboardButton,
|
||||
DataLinkButton,
|
||||
IconButton,
|
||||
PopoverContent,
|
||||
Themeable2,
|
||||
Tooltip,
|
||||
withTheme2,
|
||||
} from '@grafana/ui';
|
||||
|
||||
import { logRowToSingleRowDataFrame } from '../logsModel';
|
||||
import { getLabelTypeFromRow } from '../utils';
|
||||
|
||||
import { LogLabelStats } from './LogLabelStats';
|
||||
import { getLogRowStyles } from './getLogRowStyles';
|
||||
@@ -50,6 +59,19 @@ interface State {
|
||||
|
||||
const getStyles = memoizeOne((theme: GrafanaTheme2) => {
|
||||
return {
|
||||
labelType: css({
|
||||
border: `solid 1px ${theme.colors.text.secondary}`,
|
||||
color: theme.colors.text.secondary,
|
||||
borderRadius: theme.shape.radius.circle,
|
||||
fontSize: theme.spacing(1),
|
||||
lineHeight: theme.spacing(1.25),
|
||||
height: theme.spacing(1.5),
|
||||
width: theme.spacing(1.5),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
verticalAlign: 'middle',
|
||||
marginLeft: theme.spacing(1),
|
||||
}),
|
||||
wordBreakAll: css({
|
||||
label: 'wordBreakAll',
|
||||
wordBreak: 'break-all',
|
||||
@@ -275,6 +297,7 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
|
||||
const singleVal = parsedValues == null ? false : parsedValues.length === 1;
|
||||
const hasFilteringFunctionality = !disableActions && onClickFilterLabel && onClickFilterOutLabel;
|
||||
const refIdTooltip = app === CoreApp.Explore && row.dataFrame?.refId ? ` in query ${row.dataFrame?.refId}` : '';
|
||||
const labelType = singleKey ? getLabelTypeFromRow(parsedKeys[0], row) : null;
|
||||
|
||||
const isMultiParsedValueWithNoContent =
|
||||
!singleVal && parsedValues != null && !parsedValues.every((val) => val === '');
|
||||
@@ -321,6 +344,7 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>{labelType && <LabelTypeBadge type={labelType} styles={styles} />}</td>
|
||||
{/* Key - value columns */}
|
||||
<td className={rowStyles.logDetailsLabel}>{singleKey ? parsedKeys[0] : this.generateMultiVal(parsedKeys)}</td>
|
||||
<td className={cx(styles.wordBreakAll, wrapLogMessage && styles.wrapLine)}>
|
||||
@@ -360,7 +384,7 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
|
||||
</tr>
|
||||
{showFieldsStats && singleKey && singleVal && (
|
||||
<tr>
|
||||
<td>
|
||||
<td colSpan={2}>
|
||||
<IconButton
|
||||
variant={showFieldsStats ? 'primary' : 'secondary'}
|
||||
name="signal"
|
||||
@@ -386,6 +410,16 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
function LabelTypeBadge({ type, styles }: { type: string; styles: ReturnType<typeof getStyles> }) {
|
||||
return (
|
||||
<Tooltip content={type}>
|
||||
<div className={styles.labelType}>
|
||||
<span>{type.substring(0, 1)}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface AsyncIconButtonProps extends Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
|
||||
name: IconName;
|
||||
isActive(): Promise<boolean>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
|
||||
import { LogLabels, LogLabelsList } from './LogLabels';
|
||||
|
||||
describe('<LogLabels />', () => {
|
||||
@@ -38,8 +39,9 @@ describe('<LogLabels />', () => {
|
||||
|
||||
describe('<LogLabelsList />', () => {
|
||||
it('renders labels', () => {
|
||||
render(<LogLabelsList labels={['bar', '42']} />);
|
||||
render(<LogLabelsList labels={['bar', '42', LOG_LINE_BODY_FIELD_NAME]} />);
|
||||
expect(screen.queryByText('bar')).toBeInTheDocument();
|
||||
expect(screen.queryByText('42')).toBeInTheDocument();
|
||||
expect(screen.queryByText('log line')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import { memo, forwardRef, useMemo } from 'react';
|
||||
import { GrafanaTheme2, Labels } from '@grafana/data';
|
||||
import { Tooltip, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
|
||||
|
||||
// Levels are already encoded in color, filename is a Loki-ism
|
||||
const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
|
||||
|
||||
@@ -59,7 +61,7 @@ export const LogLabelsList = memo(({ labels }: LogLabelsArrayProps) => {
|
||||
<span className={cx([styles.logsLabels])}>
|
||||
{labels.map((label) => (
|
||||
<LogLabel key={label} styles={styles} tooltip={label}>
|
||||
{label}
|
||||
{label === LOG_LINE_BODY_FIELD_NAME ? 'log line' : label}
|
||||
</LogLabel>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { createTheme, LogLevel } from '@grafana/data';
|
||||
import { IconButton } from '@grafana/ui';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
|
||||
import { LogRowMessageDisplayedFields, Props } from './LogRowMessageDisplayedFields';
|
||||
import { createLogRow } from './__mocks__/logRow';
|
||||
import { getLogRowStyles } from './getLogRowStyles';
|
||||
@@ -42,7 +43,14 @@ const setup = (propOverrides: Partial<Props> = {}, detectedFields = ['place', 'p
|
||||
describe('LogRowMessageDisplayedFields', () => {
|
||||
it('renders diplayed fields from a log row', () => {
|
||||
setup();
|
||||
expect(screen.queryByText('Logs are wonderful')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Logs are wonderful/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/place=Earth/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/planet=Mars/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders diplayed fields and body from a log row', () => {
|
||||
setup({}, ['place', 'planet', LOG_LINE_BODY_FIELD_NAME]);
|
||||
expect(screen.queryByText(/Logs are wonderful/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/place=Earth/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/planet=Mars/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { memo, ReactNode, useMemo } from 'react';
|
||||
|
||||
import { LogRowModel, Field, LinkModel, DataFrame } from '@grafana/data';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
|
||||
import { LogRowMenuCell } from './LogRowMenuCell';
|
||||
import { LogRowStyles } from './getLogRowStyles';
|
||||
import { getAllFields } from './logParser';
|
||||
@@ -45,21 +46,26 @@ export const LogRowMessageDisplayedFields = memo((props: Props) => {
|
||||
let line = '';
|
||||
for (let i = 0; i < detectedFields.length; i++) {
|
||||
const parsedKey = detectedFields[i];
|
||||
|
||||
if (parsedKey === LOG_LINE_BODY_FIELD_NAME) {
|
||||
line += ` ${row.entry}`;
|
||||
}
|
||||
|
||||
const field = fields.find((field) => {
|
||||
const { keys } = field;
|
||||
return keys[0] === parsedKey;
|
||||
});
|
||||
|
||||
if (field) {
|
||||
if (field != null) {
|
||||
line += ` ${parsedKey}=${field.values}`;
|
||||
}
|
||||
|
||||
if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) {
|
||||
if (row.labels[parsedKey] != null && row.labels[parsedKey] != null) {
|
||||
line += ` ${parsedKey}=${row.labels[parsedKey]}`;
|
||||
}
|
||||
}
|
||||
return line.trimStart();
|
||||
}, [detectedFields, fields, row.labels]);
|
||||
}, [detectedFields, fields, row.entry, row.labels]);
|
||||
|
||||
const shouldShowMenu = useMemo(() => mouseIsOver || pinned, [mouseIsOver, pinned]);
|
||||
|
||||
|
||||
@@ -343,3 +343,48 @@ export function createLogRowsMap() {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
function getLabelTypeFromFrame(labelKey: string, frame: DataFrame, index: number): null | string {
|
||||
const typeField = frame.fields.find((field) => field.name === 'labelTypes')?.values[index];
|
||||
if (!typeField) {
|
||||
return null;
|
||||
}
|
||||
return typeField[labelKey] ?? null;
|
||||
}
|
||||
|
||||
export function getLabelTypeFromRow(label: string, row: LogRowModel) {
|
||||
if (!row.datasourceType) {
|
||||
return null;
|
||||
}
|
||||
const idField = row.dataFrame.fields.find((field) => field.name === 'id');
|
||||
if (!idField) {
|
||||
return null;
|
||||
}
|
||||
const rowIndex = idField.values.findIndex((id) => id === row.rowId);
|
||||
if (rowIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
const labelType = getLabelTypeFromFrame(label, row.dataFrame, rowIndex);
|
||||
if (!labelType) {
|
||||
return null;
|
||||
}
|
||||
return getDataSourceLabelType(labelType, row.datasourceType);
|
||||
}
|
||||
|
||||
function getDataSourceLabelType(labelType: string, datasourceType: string) {
|
||||
switch (datasourceType) {
|
||||
case 'loki':
|
||||
switch (labelType) {
|
||||
case 'I':
|
||||
return 'Indexed label';
|
||||
case 'S':
|
||||
return 'Structured metadata';
|
||||
case 'P':
|
||||
return 'Parsed label';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
import { Stack, Text, TextLink } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
import { getUnitFromMetric } from '../AutomaticMetricQueries/units';
|
||||
import { MetricScene } from '../MetricScene';
|
||||
import { StatusWrapper } from '../StatusWrapper';
|
||||
import { getUnitFromMetric } from '../autoQuery/units';
|
||||
import { reportExploreMetrics } from '../interactions';
|
||||
import { updateOtelJoinWithGroupLeft } from '../otel/util';
|
||||
import { VAR_DATASOURCE_EXPR, VAR_GROUP_BY, VAR_OTEL_GROUP_LEFT } from '../shared';
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { getQueryGeneratorFor } from './query-generators/getQueryGeneratorFor';
|
||||
import { AutoQueryInfo } from './types';
|
||||
|
||||
export function getAutoQueriesForMetric(metric: string): AutoQueryInfo {
|
||||
const metricParts = metric.split('_');
|
||||
|
||||
const suffix = metricParts.at(-1);
|
||||
|
||||
const generator = getQueryGeneratorFor(suffix);
|
||||
|
||||
if (!generator) {
|
||||
throw new Error(`Unable to generate queries for metric "${metric}" due to issues with derived suffix "${suffix}"`);
|
||||
}
|
||||
|
||||
return generator(metricParts);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { PanelBuilders } from '@grafana/scenes';
|
||||
import { HeatmapColorMode } from 'app/plugins/panel/heatmap/types';
|
||||
|
||||
import { CommonVizParams } from './types';
|
||||
|
||||
export function heatmapGraphBuilder({ title, unit }: CommonVizParams) {
|
||||
return PanelBuilders.heatmap() //
|
||||
.setTitle(title)
|
||||
.setUnit(unit)
|
||||
.setOption('calculate', false)
|
||||
.setOption('color', {
|
||||
mode: HeatmapColorMode.Scheme,
|
||||
exponent: 0.5,
|
||||
scheme: 'Spectral',
|
||||
steps: 32,
|
||||
reverse: false,
|
||||
});
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { PanelBuilders } from '@grafana/scenes';
|
||||
import { SortOrder } from '@grafana/schema';
|
||||
import { TooltipDisplayMode } from '@grafana/ui';
|
||||
|
||||
import { CommonVizParams } from './types';
|
||||
|
||||
export function percentilesGraphBuilder({ title, unit }: CommonVizParams) {
|
||||
return PanelBuilders.timeseries()
|
||||
.setTitle(title)
|
||||
.setUnit(unit)
|
||||
.setCustomFieldConfig('fillOpacity', 9)
|
||||
.setOption('tooltip', { mode: TooltipDisplayMode.Multi, sort: SortOrder.Descending })
|
||||
.setOption('legend', { showLegend: false });
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { PanelBuilders } from '@grafana/scenes';
|
||||
import { SortOrder } from '@grafana/schema';
|
||||
import { TooltipDisplayMode } from '@grafana/ui';
|
||||
|
||||
import { CommonVizParams } from './types';
|
||||
|
||||
export function simpleGraphBuilder({ title, unit }: CommonVizParams) {
|
||||
return PanelBuilders.timeseries() //
|
||||
.setTitle(title)
|
||||
.setUnit(unit)
|
||||
.setOption('legend', { showLegend: false })
|
||||
.setOption('tooltip', { mode: TooltipDisplayMode.Multi, sort: SortOrder.Descending })
|
||||
.setCustomFieldConfig('fillOpacity', 9);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export type CommonVizParams = {
|
||||
title: string;
|
||||
unit: string;
|
||||
};
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
import { VAR_METRIC_EXPR, VAR_FILTERS_EXPR, VAR_OTEL_JOIN_QUERY_EXPR } from 'app/features/trails/shared';
|
||||
|
||||
const GENERAL_BASE_QUERY = `${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}`;
|
||||
const GENERAL_RATE_BASE_QUERY = `rate(${GENERAL_BASE_QUERY}[$__rate_interval])`;
|
||||
|
||||
export function getGeneralBaseQuery(rate: boolean) {
|
||||
return rate
|
||||
? `${GENERAL_RATE_BASE_QUERY} ${VAR_OTEL_JOIN_QUERY_EXPR}`
|
||||
: `${GENERAL_BASE_QUERY} ${VAR_OTEL_JOIN_QUERY_EXPR}`;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { VAR_GROUP_BY_EXP } from '../../shared';
|
||||
import { AutoQueryDef, AutoQueryInfo } from '../types';
|
||||
|
||||
import { getGeneralBaseQuery } from './common/baseQuery';
|
||||
import { generateQueries } from './default';
|
||||
|
||||
describe('generateQueries', () => {
|
||||
const agg = 'sum';
|
||||
const unit = 'mockunit';
|
||||
|
||||
type QueryInfoKey = keyof AutoQueryInfo;
|
||||
|
||||
function testRateIndependentAssertions(queryDef: AutoQueryDef, key: QueryInfoKey) {
|
||||
describe('regardless of rate', () => {
|
||||
test(`specified unit must be propagated`, () => expect(queryDef.unit).toBe(unit));
|
||||
test(`only one query is expected`, () => expect(queryDef.queries.length).toBe(1));
|
||||
const query = queryDef.queries[0];
|
||||
test(`specified agg function must be propagated in the query expr`, () => {
|
||||
const queryAggFunction = query.expr.split('(', 2)[0];
|
||||
expect(queryAggFunction).toBe(agg);
|
||||
});
|
||||
if (key === 'breakdown') {
|
||||
const expectedSuffix = `by(${VAR_GROUP_BY_EXP})`;
|
||||
test(`breakdown query must end with "${expectedSuffix}"`, () => {
|
||||
const suffix = query.expr.substring(query.expr.length - expectedSuffix.length);
|
||||
expect(suffix).toBe(expectedSuffix);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function testRateSpecificAssertions(queryDef: AutoQueryDef, rate: boolean, key: QueryInfoKey) {
|
||||
const query = queryDef.queries[0];
|
||||
const firstParen = query.expr.indexOf('(');
|
||||
const expectedBaseQuery = getGeneralBaseQuery(rate);
|
||||
const detectedBaseQuery = query.expr.substring(firstParen + 1, firstParen + 1 + expectedBaseQuery.length);
|
||||
|
||||
const inParentheses = rate ? 'overall per-second rate' : 'overall';
|
||||
const description = `\${metric} (${inParentheses})`;
|
||||
|
||||
describe(`since rate is ${rate}`, () => {
|
||||
test(`base query must be "${expectedBaseQuery}"`, () => expect(detectedBaseQuery).toBe(expectedBaseQuery));
|
||||
if (key === 'main') {
|
||||
test(`main panel title contains expected description "${description}"`, () =>
|
||||
expect(queryDef.title).toContain(description));
|
||||
} else {
|
||||
test(`${key} panel title is just "\${metric}"`, () => expect(queryDef.title).toBe('${metric}'));
|
||||
test(`${key} panel title does not contain description "${description}"`, () =>
|
||||
expect(queryDef.title).not.toContain(description));
|
||||
}
|
||||
|
||||
if (key === 'breakdown') {
|
||||
test(`breakdown query uses "{{\${groupby}}}" as legend`, () =>
|
||||
expect(query.legendFormat).toBe('{{${groupby}}}'));
|
||||
} else {
|
||||
test(`preview query uses "${description}" as legend`, () => expect(query.legendFormat).toBe(description));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const rate of [true, false]) {
|
||||
describe(`when rate is ${rate}`, () => {
|
||||
const queryInfo = generateQueries({ agg, unit, rate });
|
||||
|
||||
let key: QueryInfoKey;
|
||||
for (key in queryInfo) {
|
||||
if (key !== 'variants') {
|
||||
const queryDef = queryInfo[key];
|
||||
describe(`queryInfo.${key}`, () => testRateIndependentAssertions(queryDef, key));
|
||||
describe(`queryInfo.${key}`, () => testRateSpecificAssertions(queryDef, rate, key));
|
||||
continue;
|
||||
}
|
||||
|
||||
queryInfo[key].forEach((queryDef, index) => {
|
||||
describe(`queryInfo.${key}[${index}]`, () => testRateIndependentAssertions(queryDef, key));
|
||||
describe(`queryInfo.${key}[${index}]`, () => testRateSpecificAssertions(queryDef, rate, key));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user