Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Mariell Hoversholm committed 2024-12-13 10:44:31 +01:00
commit 0fb8dd8760
164 files changed
+2944 -1508

No files matched your search

-5
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
v20.9.0
v22.11.0
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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
+4 -4
View File
@@ -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=
+9
View File
@@ -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
View File
@@ -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": {
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+8 -8
View File
@@ -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",
+38 -34
View File
@@ -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
+88 -34
View File
@@ -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)
})
+24 -2
View File
@@ -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
+12 -1
View File
@@ -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)
}
+27 -4
View File
@@ -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
+5 -3
View File
@@ -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
+6
View File
@@ -1676,6 +1676,12 @@ var (
Owner: grafanaOperatorExperienceSquad,
HideFromDocs: true,
},
{
Name: "elasticsearchCrossClusterSearch",
Description: "Enables cross cluster search in the Elasticsearch datasource",
Stage: FeatureStagePublicPreview,
Owner: awsDatasourcesSquad,
},
}
)
+1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
223 azureMonitorEnableUserAuth GA @grafana/partner-datasources false false false
224 alertingNotificationsStepMode experimental @grafana/alerting-squad false false true
225 feedbackButton experimental @grafana/grafana-operator-experience-squad false false false
226 elasticsearchCrossClusterSearch preview @grafana/aws-datasources false false false
+4
View File
@@ -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"
)
+15
View File
@@ -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",
+10 -8
View File
@@ -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
}
+21 -14
View File
@@ -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) {
+5 -2
View File
@@ -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"),
}
+4
View File
@@ -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 {
+9
View File
@@ -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,
})
})
}
+11 -1
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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": "",
@@ -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": "",
@@ -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';
@@ -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`, () =>
@@ -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} />);
@@ -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();
}
@@ -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;
+13 -24
View File
@@ -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]);
+45
View File
@@ -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;
};
@@ -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