diff --git a/.betterer.results b/.betterer.results
index df376f544fa..1772909d8f6 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -491,6 +491,9 @@ exports[`better eslint`] = {
"packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
+ "packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts:5381": [
+ [0, 0, 0, "Do not use any type assertions.", "0"]
+ ],
"packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
@@ -1324,8 +1327,10 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "3"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "4"]
+ [0, 0, 0, "Do not use any type assertions.", "3"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "4"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "5"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "6"]
],
"public/app/core/utils/richHistory.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
@@ -5541,7 +5546,8 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with ", "5"]
],
"public/app/features/plugins/admin/components/PluginDetailsPanel.tsx:5381": [
- [0, 0, 0, "\'@grafana/runtime/src/components/PluginPage\' import is restricted from being used by a pattern. Import from the public export instead.", "0"]
+ [0, 0, 0, "\'@grafana/runtime/src/components/PluginPage\' import is restricted from being used by a pattern. Import from the public export instead.", "0"],
+ [0, 0, 0, "No untranslated strings. Wrap text with ", "1"]
],
"public/app/features/plugins/admin/components/PluginDetailsSignature.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"],
@@ -5645,6 +5651,9 @@ exports[`better eslint`] = {
[0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
+ "public/app/features/plugins/extensions/usePluginFunctions.tsx:5381": [
+ [0, 0, 0, "Do not use any type assertions.", "0"]
+ ],
"public/app/features/plugins/extensions/usePluginLinks.tsx:5381": [
[0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"]
],
@@ -5752,10 +5761,8 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "6"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "7"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "8"],
- [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "9"],
- [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "10"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "11"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "12"]
+ [0, 0, 0, "No untranslated strings. Wrap text with ", "9"],
+ [0, 0, 0, "No untranslated strings. Wrap text with ", "10"]
],
"public/app/features/provisioning/FileHistoryPage.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with ", "0"],
@@ -5782,8 +5789,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with ", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with ", "3"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "4"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "5"]
+ [0, 0, 0, "No untranslated strings. Wrap text with ", "4"]
],
"public/app/features/provisioning/RepositoryHealth.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"],
@@ -5793,12 +5799,14 @@ exports[`better eslint`] = {
],
"public/app/features/provisioning/RepositoryListPage.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "1"],
+ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with ", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with ", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with ", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with ", "5"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "6"]
+ [0, 0, 0, "No untranslated strings. Wrap text with ", "6"],
+ [0, 0, 0, "No untranslated strings. Wrap text with ", "7"],
+ [0, 0, 0, "No untranslated strings. Wrap text with ", "8"]
],
"public/app/features/provisioning/RepositoryOverview.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with ", "0"],
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index ae2abf11a9e..28582099745 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -244,6 +244,7 @@
/devenv/dev-dashboards/extensions/ @grafana/plugins-platform-frontend
/devenv/docker/blocks/alert_webhook_listener/ @grafana/alerting-backend
+/devenv/docker/blocks/stateful_webhook/ @grafana/alerting-backend
/devenv/docker/blocks/caddy_tls/ @grafana/alerting-backend
/devenv/docker/blocks/clickhouse/ @grafana/partner-datasources
/devenv/docker/blocks/collectd/ @grafana/observability-metrics
@@ -383,6 +384,11 @@
/crowdin.yml @grafana/grafana-frontend-platform
/public/locales/ @grafana/grafana-frontend-platform
+/public/locales/de-DE @grafanabot
+/public/locales/es-ES @grafanabot
+/public/locales/fr-FR @grafanabot
+/public/locales/pt-BR @grafanabot
+/public/locales/zh-Hans @grafanabot
/public/app/core/internationalization/ @grafana/grafana-frontend-platform
/e2e/ @grafana/grafana-frontend-platform
/e2e/cloud-plugins-suite/ @grafana/partner-datasources
diff --git a/.github/workflows/i18n-crowdin-download.yml b/.github/workflows/i18n-crowdin-download.yml
index cba14abe43b..e0c3c50b9bb 100644
--- a/.github/workflows/i18n-crowdin-download.yml
+++ b/.github/workflows/i18n-crowdin-download.yml
@@ -3,7 +3,7 @@ name: Crowdin Download Action
on:
workflow_dispatch:
schedule:
- - cron: "0 * * * *"
+ - cron: "0 0 * * *"
jobs:
download-sources-from-crowdin:
@@ -12,6 +12,7 @@ jobs:
permissions:
contents: write # needed to commit changes into the PR
pull-requests: write # needed to update PR description, labels, etc
+ id-token: write # needed to get vault secrets
steps:
- name: Generate token
@@ -41,17 +42,11 @@ jobs:
pull_request_body: |
:robot: Automatic download of translations from Crowdin.
- Steps for merging:
- 1. A quick sanity check of the changes and approve. Things to look out for:
- - No changes in the English file. The source of truth is in the main branch, NOT in Crowdin.
- - Translations maybe be removed if the English phrase was removed, but there should not be many of these
- - Anything else that looks 'funky'. Ask if you're not sure.
- 2. Approve & (Auto-)merge. :tada:
+ This runs once per day and will merge automatically if all the required checks pass.
- If there's a conflict, close the pull request and **delete the branch**. A GH action will recreate the pull request.
- Remember, the longer this pull request is open, the more likely it is that it'll get conflicts.
+ If there's a conflict, close the pull request and **delete the branch**.
+ You can then either wait for the schedule to trigger a new PR, or rerun the action manually.
pull_request_labels: 'area/frontend, area/internationalization, no-changelog, no-backport'
- pull_request_reviewers: 'grafana-frontend-platform'
pull_request_base_branch_name: 'main'
base_url: 'https://grafana.api.crowdin.com'
config: 'crowdin.yml'
@@ -119,3 +114,45 @@ jobs:
with:
pr: ${{ steps.crowdin-download.outputs.pull_request_number }}
token: ${{ steps.generate_token.outputs.token }}
+
+ - name: Get vault secrets
+ id: vault-secrets
+ uses: grafana/shared-workflows/actions/get-vault-secrets@main
+ with:
+ # Secrets placed in ci/repo/grafana/grafana/grafana-pr-approver
+ repo_secrets: |
+ GRAFANA_PR_APPROVER_APP_ID=grafana-pr-approver:app-id
+ GRAFANA_PR_APPROVER_APP_PEM=grafana-pr-approver:private-key
+
+ - name: Generate approver token
+ if: steps.crowdin-download.outputs.pull_request_url
+ id: generate_approver_token
+ uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92
+ with:
+ app_id: ${{ env.GRAFANA_PR_APPROVER_APP_ID }}
+ private_key: ${{ env.GRAFANA_PR_APPROVER_APP_PEM }}
+
+ - name: Approve and automerge PR
+ if: steps.crowdin-download.outputs.pull_request_url
+ shell: bash
+ # Only approve if:
+ # - the PR does not modify files other than json files under the public/locales/ directory
+ # - the PR does not modify the en-US locale
+ run: |
+ filesChanged=$(gh pr diff --name-only ${{ steps.crowdin-download.outputs.pull_request_url }})
+
+ if [[ $(echo $filesChanged | grep -v 'public/locales/[a-zA-Z\-]*/grafana.json' | wc -l) -ne 0 ]]; then
+ echo "Non-i18n changes detected, not approving"
+ exit 1
+ fi
+
+ if [[ $(echo $filesChanged | grep "public/locales/en-US" | wc -l) -ne 0 ]]; then
+ echo "public/locales/en-US changes detected, not approving"
+ exit 1
+ fi
+
+ echo "Approving and enabling automerge"
+ gh pr review ${{ steps.crowdin-download.outputs.pull_request_url }} --approve
+ gh pr merge --auto --squash ${{ steps.crowdin-download.outputs.pull_request_url }}
+ env:
+ GITHUB_TOKEN: ${{ steps.generate_approver_token.outputs.token }}
diff --git a/.github/workflows/publish-technical-documentation-release.yml b/.github/workflows/publish-technical-documentation-release.yml
index bd77c286018..57d779660c5 100644
--- a/.github/workflows/publish-technical-documentation-release.yml
+++ b/.github/workflows/publish-technical-documentation-release.yml
@@ -3,7 +3,7 @@ name: publish-technical-documentation-release
on:
push:
branches:
- - release-v[0-9]+.[0-9]+.[0-9]+
+ - release-[0-9]+.[0-9]+.[0-9]+
tags:
- v[0-9]+.[0-9]+.[0-9]+
paths:
diff --git a/Makefile b/Makefile
index 2721c2830b8..11cee0a4255 100644
--- a/Makefile
+++ b/Makefile
@@ -271,6 +271,14 @@ test-go-integration-alertmanager: ## Run integration tests for the remote alertm
AM_URL=http://localhost:8080 AM_TENANT_ID=test \
$(GO) test $(GO_RACE_FLAG) -count=1 -run "^TestIntegrationRemoteAlertmanager" -covermode=atomic -timeout=5m ./pkg/services/ngalert/...
+.PHONY: test-go-integration-grafana-alertmanager
+test-go-integration-grafana-alertmanager: ## Run integration tests for the grafana alertmanager
+ @echo "test grafana alertmanager integration tests"
+ @export GRAFANA_VERSION=11.5.0-81938; \
+ $(GO) run tools/setup_grafana_alertmanager_integration_test_images.go; \
+ $(GO) clean -testcache; \
+ $(GO) test $(GO_RACE_FLAG) -count=1 -run "^TestAlertmanagerIntegration" -covermode=atomic -timeout=10m ./pkg/tests/alertmanager/...
+
.PHONY: test-go-integration-postgres
test-go-integration-postgres: devenv-postgres ## Run integration tests for postgres backend with flags.
@echo "test backend integration postgres tests"
diff --git a/devenv/bulk-dashboards/bulkdash.jsonnet b/devenv/bulk-dashboards/bulkdash.jsonnet
index 1a77d8abd70..05e396df2d6 100644
--- a/devenv/bulk-dashboards/bulkdash.jsonnet
+++ b/devenv/bulk-dashboards/bulkdash.jsonnet
@@ -1118,18 +1118,6 @@
"1d"
],
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "2h",
- " 6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"type": "timepicker"
},
"timezone": "browser",
diff --git a/devenv/dev-dashboards-without-uid/panel_tests_graph.json b/devenv/dev-dashboards-without-uid/panel_tests_graph.json
index b5d50f4f7b7..6bba1d9ae02 100644
--- a/devenv/dev-dashboards-without-uid/panel_tests_graph.json
+++ b/devenv/dev-dashboards-without-uid/panel_tests_graph.json
@@ -1639,8 +1639,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "browser",
"title": "Panel Tests - Graph",
diff --git a/devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json b/devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json
index 3ff76d12df2..98d49958aaf 100644
--- a/devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json
+++ b/devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json
@@ -490,17 +490,6 @@
"1h",
"2h",
"1d"
- ],
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
]
},
"timezone": "browser",
diff --git a/devenv/dev-dashboards-without-uid/panel_tests_polystat.json b/devenv/dev-dashboards-without-uid/panel_tests_polystat.json
index 951bb780017..25b1f7154e5 100644
--- a/devenv/dev-dashboards-without-uid/panel_tests_polystat.json
+++ b/devenv/dev-dashboards-without-uid/panel_tests_polystat.json
@@ -3408,8 +3408,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Panel Tests - Polystat",
diff --git a/devenv/dev-dashboards/datasource-influxdb/influxdb-templated.json b/devenv/dev-dashboards/datasource-influxdb/influxdb-templated.json
index 97719e82251..f46ccc2042f 100644
--- a/devenv/dev-dashboards/datasource-influxdb/influxdb-templated.json
+++ b/devenv/dev-dashboards/datasource-influxdb/influxdb-templated.json
@@ -312,7 +312,6 @@
"now": true,
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
"status": "Stable",
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"],
"type": "timepicker"
},
"timezone": "browser",
diff --git a/devenv/dev-dashboards/datasource-mssql/mssql_fakedata.json b/devenv/dev-dashboards/datasource-mssql/mssql_fakedata.json
index 19c5e1d0718..8fda3e8e714 100644
--- a/devenv/dev-dashboards/datasource-mssql/mssql_fakedata.json
+++ b/devenv/dev-dashboards/datasource-mssql/mssql_fakedata.json
@@ -537,8 +537,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Datasource tests - MSSQL",
diff --git a/devenv/dev-dashboards/datasource-mssql/mssql_unittest.json b/devenv/dev-dashboards/datasource-mssql/mssql_unittest.json
index 0137001067c..40f9d55e5ca 100644
--- a/devenv/dev-dashboards/datasource-mssql/mssql_unittest.json
+++ b/devenv/dev-dashboards/datasource-mssql/mssql_unittest.json
@@ -2831,8 +2831,7 @@
"to": "2018-03-15T13:55:01.000Z"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Datasource tests - MSSQL (unit test)",
diff --git a/devenv/dev-dashboards/datasource-mysql/mysql_fakedata.json b/devenv/dev-dashboards/datasource-mysql/mysql_fakedata.json
index 96e4688f7bd..cd1a24fb5bd 100644
--- a/devenv/dev-dashboards/datasource-mysql/mysql_fakedata.json
+++ b/devenv/dev-dashboards/datasource-mysql/mysql_fakedata.json
@@ -541,8 +541,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Datasource tests - MySQL",
diff --git a/devenv/dev-dashboards/datasource-mysql/mysql_unittest.json b/devenv/dev-dashboards/datasource-mysql/mysql_unittest.json
index b68c9db97ec..2f2a42c1175 100644
--- a/devenv/dev-dashboards/datasource-mysql/mysql_unittest.json
+++ b/devenv/dev-dashboards/datasource-mysql/mysql_unittest.json
@@ -2643,8 +2643,7 @@
"to": "2018-03-15T13:55:01.000Z"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Datasource tests - MySQL (unittest)",
diff --git a/devenv/dev-dashboards/datasource-postgres/postgres_fakedata.json b/devenv/dev-dashboards/datasource-postgres/postgres_fakedata.json
index d7d9514e639..750b3284517 100644
--- a/devenv/dev-dashboards/datasource-postgres/postgres_fakedata.json
+++ b/devenv/dev-dashboards/datasource-postgres/postgres_fakedata.json
@@ -577,8 +577,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Datasource tests - Postgres",
diff --git a/devenv/dev-dashboards/datasource-postgres/postgres_unittest.json b/devenv/dev-dashboards/datasource-postgres/postgres_unittest.json
index a114ed1b7ef..acec0d08a44 100644
--- a/devenv/dev-dashboards/datasource-postgres/postgres_unittest.json
+++ b/devenv/dev-dashboards/datasource-postgres/postgres_unittest.json
@@ -2621,8 +2621,7 @@
"to": "2018-03-15T13:55:01.000Z"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Datasource tests - Postgres (unittest)",
diff --git a/devenv/dev-dashboards/datasource-testdata/demo1.json b/devenv/dev-dashboards/datasource-testdata/demo1.json
index abe39ffeb55..6d8034f81e6 100644
--- a/devenv/dev-dashboards/datasource-testdata/demo1.json
+++ b/devenv/dev-dashboards/datasource-testdata/demo1.json
@@ -1092,7 +1092,6 @@
"now": true,
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
"status": "Stable",
- "time_options": ["5m", "15m", "1h", "2h", " 6h", "12h", "24h", "2d", "7d", "30d"],
"type": "timepicker"
},
"timezone": "browser",
diff --git a/devenv/dev-dashboards/datasource-testdata/new_features_in_v62.json b/devenv/dev-dashboards/datasource-testdata/new_features_in_v62.json
index 4b9535ecbbd..a002a208f85 100644
--- a/devenv/dev-dashboards/datasource-testdata/new_features_in_v62.json
+++ b/devenv/dev-dashboards/datasource-testdata/new_features_in_v62.json
@@ -1326,8 +1326,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "New Features in v6.2",
diff --git a/devenv/dev-dashboards/home.json b/devenv/dev-dashboards/home.json
index 9c3d65d4add..840d32919ea 100644
--- a/devenv/dev-dashboards/home.json
+++ b/devenv/dev-dashboards/home.json
@@ -240,17 +240,6 @@
"1h",
"2h",
"1d"
- ],
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
]
},
"timezone": "",
diff --git a/devenv/dev-dashboards/panel-bargauge/bar_gauge_demo.json b/devenv/dev-dashboards/panel-bargauge/bar_gauge_demo.json
index a708467d7be..eda6ccfe996 100644
--- a/devenv/dev-dashboards/panel-bargauge/bar_gauge_demo.json
+++ b/devenv/dev-dashboards/panel-bargauge/bar_gauge_demo.json
@@ -654,8 +654,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["2s", "5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["2s", "5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Bar Gauge Demo",
diff --git a/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge.json b/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge.json
index 29a6929cf8f..3dfa360c740 100644
--- a/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge.json
+++ b/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge.json
@@ -1423,17 +1423,6 @@
"1h",
"2h",
"1d"
- ],
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
]
},
"timezone": "",
diff --git a/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge2.json b/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge2.json
index 0f36c203cd0..06fc26e382d 100644
--- a/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge2.json
+++ b/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge2.json
@@ -519,8 +519,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Panel Tests - Bar Gauge 2",
diff --git a/devenv/dev-dashboards/panel-common/lazy_loading.json b/devenv/dev-dashboards/panel-common/lazy_loading.json
index 859eede4b4f..960c466124c 100644
--- a/devenv/dev-dashboards/panel-common/lazy_loading.json
+++ b/devenv/dev-dashboards/panel-common/lazy_loading.json
@@ -2202,8 +2202,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Lazy Loading",
diff --git a/devenv/dev-dashboards/panel-common/panels_without_title.json b/devenv/dev-dashboards/panel-common/panels_without_title.json
index 44bd210e73b..8f62c9cca5e 100644
--- a/devenv/dev-dashboards/panel-common/panels_without_title.json
+++ b/devenv/dev-dashboards/panel-common/panels_without_title.json
@@ -893,8 +893,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Panel Tests - With & Without title",
diff --git a/devenv/dev-dashboards/panel-gauge/gauge-multi-series.json b/devenv/dev-dashboards/panel-gauge/gauge-multi-series.json
index 09b72e5c030..f7ff80edc12 100644
--- a/devenv/dev-dashboards/panel-gauge/gauge-multi-series.json
+++ b/devenv/dev-dashboards/panel-gauge/gauge-multi-series.json
@@ -254,8 +254,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Panel Tests - Gauge Multi Series",
diff --git a/devenv/dev-dashboards/panel-gauge/gauge_tests.json b/devenv/dev-dashboards/panel-gauge/gauge_tests.json
index 458f53dbc08..309a255fcc7 100644
--- a/devenv/dev-dashboards/panel-gauge/gauge_tests.json
+++ b/devenv/dev-dashboards/panel-gauge/gauge_tests.json
@@ -1319,8 +1319,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Panel Tests - Gauge",
diff --git a/devenv/dev-dashboards/panel-graph/graph-gradient-area-fills.json b/devenv/dev-dashboards/panel-graph/graph-gradient-area-fills.json
index 01e9e8c2f43..c2d27efd469 100644
--- a/devenv/dev-dashboards/panel-graph/graph-gradient-area-fills.json
+++ b/devenv/dev-dashboards/panel-graph/graph-gradient-area-fills.json
@@ -372,8 +372,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Panel Tests - Graph - Gradient Area Fills",
diff --git a/devenv/dev-dashboards/panel-graph/graph-time-regions.json b/devenv/dev-dashboards/panel-graph/graph-time-regions.json
index 2031788ae3f..a4e03148536 100644
--- a/devenv/dev-dashboards/panel-graph/graph-time-regions.json
+++ b/devenv/dev-dashboards/panel-graph/graph-time-regions.json
@@ -569,8 +569,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "browser",
"title": "Panel Tests - Graph Time Regions",
diff --git a/devenv/dev-dashboards/panel-graph/graph_tests.json b/devenv/dev-dashboards/panel-graph/graph_tests.json
index 4d01c2cb534..bd1fc95d3d6 100644
--- a/devenv/dev-dashboards/panel-graph/graph_tests.json
+++ b/devenv/dev-dashboards/panel-graph/graph_tests.json
@@ -1639,8 +1639,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "browser",
"title": "Panel Tests - Graph",
diff --git a/devenv/dev-dashboards/panel-polystat/polystat_test.json b/devenv/dev-dashboards/panel-polystat/polystat_test.json
index 6be355ebd99..faa84463019 100644
--- a/devenv/dev-dashboards/panel-polystat/polystat_test.json
+++ b/devenv/dev-dashboards/panel-polystat/polystat_test.json
@@ -3408,8 +3408,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Panel Tests - Polystat",
diff --git a/devenv/dev-dashboards/panel-table/table_tests.json b/devenv/dev-dashboards/panel-table/table_tests.json
index 8582ef068d7..b8ca436e110 100644
--- a/devenv/dev-dashboards/panel-table/table_tests.json
+++ b/devenv/dev-dashboards/panel-table/table_tests.json
@@ -440,8 +440,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "browser",
"title": "Panel Tests - Table",
diff --git a/devenv/dev-dashboards/panel-timeseries/timeseries-gradient-area.json b/devenv/dev-dashboards/panel-timeseries/timeseries-gradient-area.json
index a7389cc6ed6..8f3267a62d7 100644
--- a/devenv/dev-dashboards/panel-timeseries/timeseries-gradient-area.json
+++ b/devenv/dev-dashboards/panel-timeseries/timeseries-gradient-area.json
@@ -562,8 +562,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Panel Tests - Graph NG - Gradient Area Fills",
diff --git a/devenv/dev-dashboards/scenarios/slow_queries_and_annotations.json b/devenv/dev-dashboards/scenarios/slow_queries_and_annotations.json
index a7cc41acd8f..8966137b55e 100644
--- a/devenv/dev-dashboards/scenarios/slow_queries_and_annotations.json
+++ b/devenv/dev-dashboards/scenarios/slow_queries_and_annotations.json
@@ -1132,8 +1132,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Panel tests - Slow Queries & Annotations",
diff --git a/devenv/dev-dashboards/scenarios/time_zone_support.json b/devenv/dev-dashboards/scenarios/time_zone_support.json
index feb317f917e..cc1f81ee221 100644
--- a/devenv/dev-dashboards/scenarios/time_zone_support.json
+++ b/devenv/dev-dashboards/scenarios/time_zone_support.json
@@ -684,8 +684,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "utc",
"title": "Panel Tests - Time zone support",
diff --git a/devenv/docker/blocks/prometheus_random_data/Dockerfile b/devenv/docker/blocks/prometheus_random_data/Dockerfile
index c4ebc8cb0ec..41f90677409 100644
--- a/devenv/docker/blocks/prometheus_random_data/Dockerfile
+++ b/devenv/docker/blocks/prometheus_random_data/Dockerfile
@@ -7,7 +7,7 @@ RUN CGO_ENABLED=0 GOOS=linux go install -tags netgo -ldflags '-w' github.com/pro
# Final image.
FROM scratch
-LABEL maintainer "The Prometheus Authors "
+LABEL maintainer="The Prometheus Authors "
COPY --from=builder /go/bin/random .
EXPOSE 8080
ENTRYPOINT ["/random"]
diff --git a/devenv/docker/blocks/stateful_webhook/Dockerfile b/devenv/docker/blocks/stateful_webhook/Dockerfile
new file mode 100644
index 00000000000..03b50db2135
--- /dev/null
+++ b/devenv/docker/blocks/stateful_webhook/Dockerfile
@@ -0,0 +1,12 @@
+FROM golang:1.23.5
+
+ADD main.go /go/src/webhook/main.go
+
+WORKDIR /go/src/webhook
+
+RUN mkdir /tmp/logs
+RUN go build -o /bin main.go
+
+ENV PORT=8080
+
+ENTRYPOINT [ "/bin/main" ]
diff --git a/devenv/docker/blocks/stateful_webhook/docker-compose.yaml b/devenv/docker/blocks/stateful_webhook/docker-compose.yaml
new file mode 100644
index 00000000000..7217516c4e9
--- /dev/null
+++ b/devenv/docker/blocks/stateful_webhook/docker-compose.yaml
@@ -0,0 +1,5 @@
+ stateful_webhook:
+ build:
+ context: docker/blocks/stateful_webhook
+ ports:
+ - "8080:8080"
diff --git a/devenv/docker/blocks/stateful_webhook/main.go b/devenv/docker/blocks/stateful_webhook/main.go
new file mode 100644
index 00000000000..926cf4e593e
--- /dev/null
+++ b/devenv/docker/blocks/stateful_webhook/main.go
@@ -0,0 +1,149 @@
+package main
+
+import (
+ "encoding/json"
+ "io"
+ "log"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+type Event struct {
+ Status string `json:"status"`
+ TimeNow time.Time `json:"timeNow"`
+ StartsAt time.Time `json:"startsAt"`
+ Node string `json:"node"`
+ DeltaLastSeconds float64 `json:"deltaLastSeconds"`
+ DeltaStartSeconds float64 `json:"deltaStartSeconds"`
+}
+
+type Notification struct {
+ Alerts []Alert `json:"alerts"`
+ CommonAnnotations map[string]string `json:"commonAnnotations"`
+ CommonLabels map[string]string `json:"commonLabels"`
+ ExternalURL string `json:"externalURL"`
+ GroupKey string `json:"groupKey"`
+ GroupLabels map[string]string `json:"groupLabels"`
+ Message string `json:"message"`
+ OrgID int `json:"orgId"`
+ Receiver string `json:"receiver"`
+ State string `json:"state"`
+ Status string `json:"status"`
+ Title string `json:"title"`
+ TruncatedAlerts int `json:"truncatedAlerts"`
+ Version string `json:"version"`
+}
+
+type Alert struct {
+ Annotations map[string]string `json:"annotations"`
+ DashboardURL string `json:"dashboardURL"`
+ StartsAt time.Time `json:"startsAt"`
+ EndsAt time.Time `json:"endsAt"`
+ Fingerprint string `json:"fingerprint"`
+ GeneratorURL string `json:"generatorURL"`
+ Labels map[string]string `json:"labels"`
+ PanelURL string `json:"panelURL"`
+ SilenceURL string `json:"silenceURL"`
+ Status string `json:"status"`
+ ValueString string `json:"valueString"`
+ Values map[string]any `json:"values"`
+}
+
+type NotificationHandler struct {
+ startedAt time.Time
+ stats map[string]int
+ hist []Event
+ m sync.Mutex
+}
+
+func NewNotificationHandler() *NotificationHandler {
+ return &NotificationHandler{
+ startedAt: time.Now(),
+ stats: make(map[string]int),
+ hist: make([]Event, 0),
+ }
+}
+
+func (ah *NotificationHandler) Notify(w http.ResponseWriter, r *http.Request) {
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ log.Println(err)
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ n := Notification{}
+ if err := json.Unmarshal(b, &n); err != nil {
+ log.Println(err)
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ log.Printf("got notification from: %s. a: %v", r.RemoteAddr, n)
+
+ ah.m.Lock()
+ defer ah.m.Unlock()
+
+ addr := r.RemoteAddr
+ if split := strings.Split(r.RemoteAddr, ":"); len(split) > 0 {
+ addr = split[0]
+ }
+
+ a := n.Alerts[0]
+
+ timeNow := time.Now()
+
+ ah.stats[n.Status]++
+
+ var d time.Duration
+ if len(ah.hist) > 0 {
+ last := ah.hist[len(ah.hist)-1]
+ d = timeNow.Sub(last.TimeNow)
+ }
+
+ ah.hist = append(ah.hist, Event{
+ Status: n.Status,
+ StartsAt: a.StartsAt,
+ TimeNow: timeNow,
+ Node: addr,
+ DeltaLastSeconds: d.Seconds(),
+ DeltaStartSeconds: timeNow.Sub(ah.startedAt).Seconds(),
+ })
+}
+
+func (ah *NotificationHandler) GetNotifications(w http.ResponseWriter, _ *http.Request) {
+ ah.m.Lock()
+ defer ah.m.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+
+ res, err := json.MarshalIndent(map[string]any{"stats": ah.stats, "history": ah.hist}, "", "\t")
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ //nolint:errcheck
+ w.Write([]byte(`{"error":"failed to marshal alerts"}`))
+ log.Printf("failed to marshal alerts: %v\n", err)
+ return
+ }
+
+ log.Printf("requested current state\n%v\n", string(res))
+
+ _, err = w.Write(res)
+ if err != nil {
+ log.Printf("failed to write response: %v\n", err)
+ }
+}
+
+func main() {
+ ah := NewNotificationHandler()
+
+ http.HandleFunc("/ready", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+
+ http.HandleFunc("/notify", ah.Notify)
+ http.HandleFunc("/notifications", ah.GetNotifications)
+
+ log.Println("Listening")
+ //nolint:errcheck
+ http.ListenAndServe("0.0.0.0:8080", nil)
+}
diff --git a/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/alerts/overview.json b/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/alerts/overview.json
index b4946cfb6b3..0357c3d10f1 100644
--- a/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/alerts/overview.json
+++ b/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/alerts/overview.json
@@ -151,17 +151,6 @@
"1h",
"2h",
"1d"
- ],
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
]
},
"timezone": "",
diff --git a/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/mysql/overview.json b/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/mysql/overview.json
index 7643250ec28..2bf789366cc 100644
--- a/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/mysql/overview.json
+++ b/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/mysql/overview.json
@@ -5376,17 +5376,6 @@
"1d"
],
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"type": "timepicker"
},
"timezone": "browser",
diff --git a/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json b/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json
index b4946cfb6b3..0357c3d10f1 100644
--- a/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json
+++ b/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json
@@ -151,17 +151,6 @@
"1h",
"2h",
"1d"
- ],
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
]
},
"timezone": "",
diff --git a/devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json b/devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json
index 7643250ec28..2bf789366cc 100644
--- a/devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json
+++ b/devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json
@@ -5376,17 +5376,6 @@
"1d"
],
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"type": "timepicker"
},
"timezone": "browser",
diff --git a/docs/sources/administration/roles-and-permissions/access-control/plan-rbac-rollout-strategy/index.md b/docs/sources/administration/roles-and-permissions/access-control/plan-rbac-rollout-strategy/index.md
index 09c2fc4c3d4..e04c2fcecf2 100644
--- a/docs/sources/administration/roles-and-permissions/access-control/plan-rbac-rollout-strategy/index.md
+++ b/docs/sources/administration/roles-and-permissions/access-control/plan-rbac-rollout-strategy/index.md
@@ -369,9 +369,11 @@ Here are two ways to achieve this:
# Update the role
curl -H 'Authorization: Bearer glsa_kcVxDhZtu5ISOZIEt' -H 'Content-Type: application/json' \
- -X PUT-d @/tmp/basic_viewer.json '/api/access-control/roles/basic_viewer'
+ -X PUT -d @/tmp/basic_viewer.json '/api/access-control/roles/basic_viewer'
```
+ The token that is used in this request is the [service account token](ref:service-accounts).
+
- Or use the `role > from` list and `permission > state` option of your provisioning file:
```yaml
@@ -394,6 +396,20 @@ Here are two ways to achieve this:
state: 'present'
```
+ If your goal is to remove an access to an app you should remove it from the role and update it. For example:
+
+ ```bash
+ # Fetch the role, modify it to remove permissions to kentik-connect-app and increment role version
+ curl -H 'Authorization: Bearer glsa_kcVxDhZtu5ISOZIEt' \
+ -X GET '/api/access-control/roles/basic_viewer' | \
+ jq 'del(.created)| del(.updated) | del(.permissions[].created) | del(.permissions[].updated) | .version += 1' | \
+ jq 'del(.permissions[] | select (.action == "plugins.app:access" and .scope == "plugins:id:kentik-connect-app"))'
+
+ # Update the role
+ curl -H 'Authorization: Bearer glsa_kcVxDhZtu5ISOZIEt' -H 'Content-Type: application/json' \
+ -X PUT -d @/tmp/basic_viewer.json '/api/access-control/roles/basic_viewer'
+ ```
+
### Manage user permissions through teams
In the scenario where you want users to grant access by the team they belong to, we recommend to set users role to `No Basic Role` and let the team assignment assign the role instead.
diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/_index.md b/docs/sources/alerting/configure-notifications/manage-contact-points/_index.md
index 160d76360dc..215ed1926dc 100644
--- a/docs/sources/alerting/configure-notifications/manage-contact-points/_index.md
+++ b/docs/sources/alerting/configure-notifications/manage-contact-points/_index.md
@@ -140,13 +140,13 @@ Each contact point integration has its own configuration options and setup proce
- [Discord](ref:discord)
- [Email](ref:email)
- [Google Chat](ref:gchat)
-- [Grafana Oncall](ref:oncall)
+- [Grafana OnCall](ref:oncall)
- Kafka REST Proxy
- Line
- [Microsoft Teams](ref:teams)
- [MQTT](ref:mqtt)
- [Opsgenie](ref:opsgenie)
-- [Pagerduty](ref:pagerduty)
+- [PagerDuty](ref:pagerduty)
- Pushover
- Sensu Go
- [Slack](ref:slack)
diff --git a/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md b/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md
index 44b20434a0d..0fff2d65d9f 100644
--- a/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md
+++ b/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md
@@ -138,12 +138,12 @@ The grid has a negative gravity that moves panels up if there is empty space abo
"nowDelay": "",
"quick_ranges": [
{
- "display": "Last 6 hours"
+ "display": "Last 6 hours",
"from": "now-6h",
"to": "now"
},
{
- "display": "Last 7 days"
+ "display": "Last 7 days",
"from": "now-7d",
"to": "now"
}
diff --git a/docs/sources/datasources/tempo/_index.md b/docs/sources/datasources/tempo/_index.md
index 8b2123b5cd4..836db6cac14 100644
--- a/docs/sources/datasources/tempo/_index.md
+++ b/docs/sources/datasources/tempo/_index.md
@@ -71,7 +71,14 @@ refs:
# Tempo data source
-Grafana ships with built-in support for [Tempo](https://grafana.com/docs/tempo/latest/), a high-volume, minimal-dependency trace storage, open source tracing solution from Grafana Labs. This topic explains configuration and queries specific to the Tempo data source.
+Grafana ships with built-in support for [Tempo](https://grafana.com/docs/tempo//), a high-volume, minimal-dependency trace storage, open source tracing solution from Grafana Labs.
+
+To learn more about traces, refer to [Introduction to tracing](https://grafana.com/docs/tempo//introduction/).
+
+To use traces, you need you have an application or service that is instrumented to emit traces.
+Refer to the [Instrument for tracing](https://grafana.com/docs/tempo//getting-started/instrumentation/) for more information.
+
+## Add a data source
For instructions on how to add a data source to Grafana, refer to the [administration documentation](ref:data-source-management).
Only users with the organization administrator role can add data sources.
@@ -81,6 +88,8 @@ This video explains how to add data sources, including Loki, Tempo, and Mimir, t
{{< youtube id="cqHO0oYW6Ic" start="298" >}}
-Once you've added the data source, you can [configure it]({{< relref "./configure-tempo-data-source/" >}}) so that your Grafana instance's users can create queries in its [query editor]({{< relref "./query-editor/" >}}) when they [build dashboards](ref:build-dashboards) and use [Explore](ref:explore).
+## Learn more
+
+After you've added the data source, you can [configure it](./configure-tempo-data-source/) so that your Grafana instance's users can create queries in its [query editor](./query-editor/) when they [build dashboards](ref:build-dashboards) and use [Explore](ref:explore).
{{< section withDescriptions="true">}}
diff --git a/docs/sources/datasources/tempo/configure-tempo-data-source.md b/docs/sources/datasources/tempo/configure-tempo-data-source.md
index 24844b82ef3..39d3cdf751a 100644
--- a/docs/sources/datasources/tempo/configure-tempo-data-source.md
+++ b/docs/sources/datasources/tempo/configure-tempo-data-source.md
@@ -131,7 +131,7 @@ To use streaming, you need to:
- Run Tempo version 2.2 or later, or Grafana Enterprise Traces (GET) version 2.2 or later, or use Grafana Cloud Traces.
- Tempo must have `stream_over_http_enabled: true` for streaming to work.
- For more information, refer to [Tempo GRPC API](https://grafana.com/docs/tempo//api_docs/#tempo-grpc-api).
+ For more information, refer to [Tempo gRPC API](https://grafana.com/docs/tempo//api_docs/#tempo-grpc-api).
- For self-managed Tempo or GET instances: If your Tempo or GET instance is behind a load balancer or proxy that doesn't supporting gRPC or HTTP2, streaming may not work and should be deactivated.
diff --git a/docs/sources/datasources/tempo/query-editor/_index.md b/docs/sources/datasources/tempo/query-editor/_index.md
index b561a54bdcd..b6aa5c0f49b 100644
--- a/docs/sources/datasources/tempo/query-editor/_index.md
+++ b/docs/sources/datasources/tempo/query-editor/_index.md
@@ -105,7 +105,7 @@ Refer to the [Search using the TraceQL query builder documentation]({{< relref "
The **TraceQL** query editor lets you search by trace ID and write TraceQL queries using autocomplete.
-Refer to the [TraceQL query editor documentation]({{< relref "./traceql-editor" >}}) to learn more about constructing queries using a code-editor-like experience.
+Refer to the [TraceQL query editor documentation](./traceql-editor) to learn more about constructing queries using a code-editor-like experience.

@@ -113,8 +113,8 @@ You can also search for a trace ID by entering it into the query field.
### Service graph view
-Grafana’s **Service Graph** view uses metrics to display span request rates, error rates, and durations, as well as service graphs.
-Once the requirements are set up, this preconfigured view is immediately available.
+The **Service Graph** view uses metrics to display span request rates, error rates, and durations, as well as service graphs.
+After the requirements are set up, this preconfigured view is immediately available.
Using the service graph view, you can:
diff --git a/docs/sources/datasources/tempo/query-editor/traceql-editor.md b/docs/sources/datasources/tempo/query-editor/traceql-editor.md
index 7752a290229..cc5b38beaaa 100644
--- a/docs/sources/datasources/tempo/query-editor/traceql-editor.md
+++ b/docs/sources/datasources/tempo/query-editor/traceql-editor.md
@@ -12,7 +12,7 @@ labels:
- oss
menuTitle: Write TraceQL queries
title: Write TraceQL queries with the editor
-weight: 300
+weight: 400
refs:
explore:
- pattern: /docs/grafana/
@@ -41,4 +41,4 @@ refs:
[//]: # 'Shared content for the TraceQL query editor'
[//]: # 'This content is located in /docs/sources/shared/datasources/tempo-editor-traceql.md'
-{{< docs/shared source="grafana" lookup="datasources/tempo-editor-traceql.md" version="" >}}
+{{< docs/shared source="grafana" lookup="datasources/tempo-editor-traceql.md" version="" >}}
diff --git a/docs/sources/datasources/tempo/query-editor/traceql-search.md b/docs/sources/datasources/tempo/query-editor/traceql-search.md
index b885f1239d8..84ac0b396c4 100644
--- a/docs/sources/datasources/tempo/query-editor/traceql-search.md
+++ b/docs/sources/datasources/tempo/query-editor/traceql-search.md
@@ -56,4 +56,4 @@ To enable the TraceQL query builder in self-hosted Grafana through version 10.1,
[//]: # 'Shared content for the Search - TraceQL query builder'
-{{< docs/shared source="grafana" lookup="datasources/tempo-search-traceql.md" leveloffset="+1" version="" >}}
+{{< docs/shared source="grafana" lookup="datasources/tempo-search-traceql.md" leveloffset="+1" version="" >}}
diff --git a/docs/sources/datasources/tempo/query-editor/traceql-structure.md b/docs/sources/datasources/tempo/query-editor/traceql-structure.md
new file mode 100644
index 00000000000..b67d57cedf3
--- /dev/null
+++ b/docs/sources/datasources/tempo/query-editor/traceql-structure.md
@@ -0,0 +1,21 @@
+---
+description: Learn how to create TraceQL queries are structured.
+keywords:
+ - queries
+ - TraceQL
+labels:
+ products:
+ - cloud
+ - enterprise
+ - oss
+menuTitle: TraceQL query structure
+title: TraceQL query structure
+weight: 200
+---
+
+# TraceQL query structure
+
+[//]: # 'Shared content for best practices for traces'
+[//]: # 'This content is located in /tempo/docs/sources/shared/trace-structure.md'
+
+{{< docs/shared source="tempo" lookup="traceql-query-structure.md" version="" >}}
diff --git a/docs/sources/datasources/tempo/service-graph.md b/docs/sources/datasources/tempo/service-graph.md
index 0e6c1f8b657..d1644d41b73 100644
--- a/docs/sources/datasources/tempo/service-graph.md
+++ b/docs/sources/datasources/tempo/service-graph.md
@@ -72,8 +72,8 @@ You use the Service Graph to detect performance issues; track increases in error
## Display the Service Graph
-1. [Configure Grafana Alloy](https://grafana.com/docs/tempo/latest/configuration/grafana-alloy/) or [Tempo or GET](https://grafana.com/docs/tempo/latest/metrics-generator/service_graphs/#tempo) to generate Service Graph data.
-1. Link a Prometheus data source in the Tempo data source's [Service Graph]({{< relref "./configure-tempo-data-source#configure-service-graph" >}}) settings.
+1. [Configure Grafana Alloy](https://grafana.com/docs/tempo//configuration/grafana-alloy/) or [Tempo or GET](https://grafana.com/docs/tempo//metrics-generator/service_graphs/#tempo) to generate Service Graph data.
+1. Link a Prometheus data source in the Tempo data source's [Service Graph](./configure-tempo-data-source#configure-service-graph) settings.
1. Navigate to [Explore](ref:explore).
1. Select the Tempo data source.
1. Select the **Service Graph** query type.
@@ -113,10 +113,10 @@ To open the Service Graph view:
1. Run the query.
1. _(Optional)_ Filter your results.
-{{% admonition type="note" %}}
+{{< admonition type="note" >}}
Grafana uses the `traces_spanmetrics_calls_total` metric to display the name, rate, and error rate columns, and `traces_spanmetrics_latency_bucket` to display the duration column.
These metrics must exist in your Prometheus data source.
-{{% /admonition %}}
+{{< /admonition >}}
To open a query in Prometheus with the span name of that row automatically set in the query, click a row in the **rate**, **error rate**, or **duration** columns.
diff --git a/docs/sources/datasources/tempo/traces-in-grafana/_index.md b/docs/sources/datasources/tempo/traces-in-grafana/_index.md
new file mode 100644
index 00000000000..c3f622bb57d
--- /dev/null
+++ b/docs/sources/datasources/tempo/traces-in-grafana/_index.md
@@ -0,0 +1,23 @@
+---
+description: Learn about how you can use tracing data in Grafana Cloud to query data,
+ generate metrics, and link your tracing data with logs, metrics, and profiles.
+keywords:
+ - Grafana
+ - traces
+ - tracing
+title: Use traces in Grafana
+weight: 275
+---
+
+# Use traces in Grafana
+
+Using traces, you can search for traces, generate metrics from spans, and link your tracing data with logs, metrics, and profiles.
+
+This page provides a summary of how you can use tracing data in Grafana.
+
+For general documentation on querying data sources in Grafana, refer to [Query and transform data](https://grafana.com/docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/).
+
+[//]: # 'Shared content for best practices for traces'
+[//]: # 'This content is located in /tempo/docs/sources/shared/tempo-in-grafana.md'
+
+{{< docs/shared source="tempo" lookup="tempo-in-grafana.md" version="" >}}
diff --git a/docs/sources/datasources/tempo/json-trace-file.md b/docs/sources/datasources/tempo/traces-in-grafana/json-trace-file.md
similarity index 82%
rename from docs/sources/datasources/tempo/json-trace-file.md
rename to docs/sources/datasources/tempo/traces-in-grafana/json-trace-file.md
index 0387eab883a..77b45011a2f 100644
--- a/docs/sources/datasources/tempo/json-trace-file.md
+++ b/docs/sources/datasources/tempo/traces-in-grafana/json-trace-file.md
@@ -12,7 +12,9 @@ labels:
- oss
menuTitle: Upload JSON trace file
title: Upload a JSON trace file
-weight: 400
+weight: 900
+aliases:
+ - ../json-trace-file/ # /docs/grafana/latest/datasources/tempo/json-trace-file
---
# Upload a JSON trace file
@@ -20,7 +22,15 @@ weight: 400
You can upload a JSON file that contains a single trace and visualize it.
If the file has multiple traces, Grafana visualizes the first trace.
-**To download a trace or Service Graph through the inspector:**
+To upload a trace file:
+
+1. Select **Explore** in Grafana.
+1. Select **Import trace** in the right corner.
+1. Upload your JSON trace file.
+
+## Download a trace or service graph
+
+To download a trace or Service Graph through the [Inspector panel](https://grafana.com/docs/grafana//explore/explore-inspector/):
1. Open the inspector.
1. Navigate to the **Data** tab.
diff --git a/docs/sources/datasources/tempo/link-trace-id.md b/docs/sources/datasources/tempo/traces-in-grafana/link-trace-id.md
similarity index 87%
rename from docs/sources/datasources/tempo/link-trace-id.md
rename to docs/sources/datasources/tempo/traces-in-grafana/link-trace-id.md
index d53496e3784..07c7f6f2bec 100644
--- a/docs/sources/datasources/tempo/link-trace-id.md
+++ b/docs/sources/datasources/tempo/traces-in-grafana/link-trace-id.md
@@ -12,7 +12,10 @@ labels:
- oss
menuTitle: Link to a trace ID
title: Link to a trace ID
-weight: 700
+weight: 800
+aliases:
+ - ../link-trace-id/ # /docs/grafana/latest/datasources/tempo/link-trace-id/
+
refs:
configure-grafana-feature-toggles:
- pattern: /docs/grafana/
@@ -69,10 +72,10 @@ You can link to Tempo traces from logs or metrics.
You can link to Tempo traces from logs in Loki, Elasticsearch, Splunk, and other logs data sources by configuring an internal link.
-To configure this feature, see the [Derived fields]({{< relref "../loki#configure-derived-fields" >}}) section of the Loki data source docs or the [Data links]({{< relref "../elasticsearch#data-links" >}}) section of the Elasticsearch or Splunk data source docs.
+To configure this feature, refer to the [Derived fields](../../loki#configure-derived-fields) section of the Loki data source docs or the [Data links](../../elasticsearch#data-links) section of the Elasticsearch or Splunk data source docs.
## Link to a trace ID from metrics
You can link to Tempo traces from metrics in Prometheus data sources by configuring an exemplar.
-To configure this feature, see the [introduction to exemplars](ref:exemplars) documentation.
+To configure this feature, refer to the [Exemplars](ref:exemplars) documentation.
diff --git a/docs/sources/explore/correlations-editor-in-explore.md b/docs/sources/explore/correlations-editor-in-explore.md
index 50053226158..dd0a0ce1936 100644
--- a/docs/sources/explore/correlations-editor-in-explore.md
+++ b/docs/sources/explore/correlations-editor-in-explore.md
@@ -3,6 +3,7 @@ labels:
products:
- enterprise
- oss
+ - cloud
title: Correlations Editor in Explore
weight: 20
---
@@ -13,22 +14,22 @@ weight: 20
The Explore editor is available in 10.1 and later versions. In the editor, transformations is available in Grafana 10.3 and later versions.
{{% /admonition %}}
-Correlations allow users to build a link between any two data sources. For more information about correlations in general, please see the [correlations]({{< relref "../administration/correlations" >}}) topic in the administration page.
+Correlations allow users to build a link between any two data sources. For more information about correlations in general, please see the [correlations](/docs/grafana//administration/correlations/) topic in the administration page.
## Create a correlation
1. In Grafana, navigate to the Explore page.
-1. Select a data source that you would like to be [the source data source]({{< relref "../administration/correlations/correlation-configuration#source-data-source-and-result-field" >}}) for a new correlation.
-1. Run a query producing data in [a supported visualization]({{< relref "../administration/correlations#correlations" >}}).
-1. Click **+ Add** in the top toolbar and select **Add correlation** (you can also select **Correlations Editor** from the [Command Palette]({{< relref "../search#command-palette" >}})).
+1. Select a data source that you would like to be [the source data source](/docs/grafana//administration/correlations/correlation-configuration/#source-data-source-and-result-field) for a new correlation.
+1. Run a query producing data in [a supported visualization](/docs/grafana//administration/correlations/#correlations).
+1. Click **+ Add** in the top toolbar and select **Add correlation** (you can also select **Correlations Editor** from the [Command Palette](/docs/grafana//search/#command-palette)).
1. Explore is now in Correlations Editor mode indicated by a blue border and top bar. You can exit Correlations Editor by clicking **Exit** in the top bar.
1. You can now create the following new correlations for the visualization with links that are attached to the data that you can use to build a new query:
- Logs: links are displayed next to field values inside log details for each log row
- Table: every table cell is a link
1. Click on a link to add a new correlation.
- Links are associated with a field that is used as a [result field of a correlation]({{< relref "../administration/correlations/correlation-configuration" >}}).
-1. In the split view that opens, use the right pane to set up [the target query source of the correlation]({{< relref "../administration/correlations/correlation-configuration#target-query" >}}).
-1. Build a target query using [variables syntax]({{< relref "../dashboards/variables/variable-syntax" >}}) with variables from the list provided at the top of the pane. The list contains sample values from the selected data row.
+ Links are associated with a field that is used as a [result field of a correlation](/docs/grafana//administration/correlations/correlation-configuration/).
+1. In the split view that opens, use the right pane to set up [the target query source of the correlation](/docs/grafana//administration/correlations/correlation-configuration/#target-query).
+1. Build a target query using [variables syntax](/docs/grafana//dashboards/variables/variable-syntax/) with variables from the list provided at the top of the pane. The list contains sample values from the selected data row.
1. Provide a label and description (optional).
A label will be used as the name of the link inside the visualization and can contain variables.
1. Provide transformations (optional; see below for details).
@@ -37,7 +38,7 @@ Correlations allow users to build a link between any two data sources. For more
## Transformations
-Transformations allow you to extract values that exist in a field with other data. For example, using a transformation, you can extract one portion of a log line to use in a correlation. For more details on transformations in correlations, see [Correlations]({{< relref "../administration/correlations/correlation-configuration/#correlation-transformations" >}}).
+Transformations allow you to extract values that exist in a field with other data. For example, using a transformation, you can extract one portion of a log line to use in a correlation. For more details on transformations in correlations, see [Correlations](/docs/grafana//explore/correlations-editor-in-explore/#transformations).
After clicking one of the generated links in the editor mode, you can add transformations by clicking **Add transformation** in the Transformations dropdown menu.
@@ -47,7 +48,7 @@ You can use a transformation in your correlation with the following steps:
Select the portion of the field that you want to use for the transformation. For example, a log line.
Once selected, the value of this field will be used to assist you in building the transformation.
1. Select the type of the transformation.
- See [correlations]({{< relref "../administration/correlations/correlation-configuration/#correlation-transformations" >}}) for the options and relevant settings.
+ See [correlations](/docs/grafana//explore/correlations-editor-in-explore/#transformations) for the options and relevant settings.
1. Based on your selection, you might see one or more variables populate, or you might need to provide more specifications in options that are displayed.
1. Select **Add transformation to correlation** to add the specified variables to the list of available variables.
@@ -57,7 +58,7 @@ For regular expressions in this dialog box, the `mapValue` referred to in other
## Correlations examples
-The following examples show how to create correlations using the Correlations Editor in Explore. If you'd like to follow these examples, make sure to set up a [test data source]({{< relref "../datasources/testdata#testdata-data-source" >}}).
+The following examples show how to create correlations using the Correlations Editor in Explore. If you'd like to follow these examples, make sure to set up a [test data source](/docs/grafana//datasources/testdata/#testdata-data-source).
### Create a text to graph correlation
@@ -65,7 +66,7 @@ This example shows how to create a correlation using Correlations Editor in Expl
Correlations allow you to use results of one query to run a new query in any data source. In this example, you will run a query that renders tabular data. The data will be used to run a different query that yields a graph result.
-To follow this example, make sure you have set up [a test data source]({{< relref "../datasources/testdata#testdata-data-source" >}}).
+To follow this example, make sure you have set up [a test data source](/docs/grafana//datasources/testdata/#testdata-data-source).
1. In Grafana, navigate to **Explore**.
1. Select the **test data source** from the dropdown menu at the top left of the page.
@@ -100,7 +101,7 @@ You can apply the same steps to any data source. Correlations allow you to creat
In this example, you will create a correlation to demonstrate how to use transformations to extract values from the log line and another field.
-To follow this example, make sure you have set up [a test data source]({{< relref "../datasources/testdata#testdata-data-source" >}}).
+To follow this example, make sure you have set up [a test data source](/docs/grafana//datasources/testdata/#testdata-data-source).
1. In Grafana, navigate to **Explore**.
1. Select the **test data source** from the dropdown menu at the top left of the page.
diff --git a/docs/sources/explore/trace-integration.md b/docs/sources/explore/trace-integration.md
index f9b0779b12d..532bb3ca8f2 100644
--- a/docs/sources/explore/trace-integration.md
+++ b/docs/sources/explore/trace-integration.md
@@ -157,7 +157,8 @@ The node graph requires data to be returned from the data source in a specific f
## Service graph
-A service graph visualizes span metrics, including rates, error rates, and durations (RED), along with service relationships. Once the requirements are configured, this pre-configured view is immediately available.
+A service graph visualizes rates, error rates, and durations (RED), along with service relationships.
+After the requirements are configured, this pre-configured view is immediately available.
For additional information refer to the following documentation:
diff --git a/docs/sources/introduction/grafana-enterprise.md b/docs/sources/introduction/grafana-enterprise.md
index 28a647254ef..e9317e632a4 100644
--- a/docs/sources/introduction/grafana-enterprise.md
+++ b/docs/sources/introduction/grafana-enterprise.md
@@ -33,7 +33,7 @@ Grafana Enterprise includes integrations with more ways to authenticate your use
Supported auth providers:
-- [Auth Proxy]({{< relref "../setup-grafana/configure-security/configure-authentication/auth-proxy#team-sync-enterprise-only" >}})
+- [Auth Proxy](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/auth-proxy#team-sync-enterprise-only)
- [Azure AD](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/azuread#group-sync-enterprise-only)
- [Generic OAuth integration](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/generic-oauth#configure-group-synchronization)
- [GitHub OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/github#configure-group-synchronization)
diff --git a/docs/sources/panels-visualizations/configure-data-links/index.md b/docs/sources/panels-visualizations/configure-data-links/index.md
index 31bf6df9b67..0245bc75eb5 100644
--- a/docs/sources/panels-visualizations/configure-data-links/index.md
+++ b/docs/sources/panels-visualizations/configure-data-links/index.md
@@ -25,9 +25,9 @@ weight: 80
refs:
global-variables:
- pattern: /docs/grafana/
- destination: /docs/grafana//dashboards/variables/add-template-variables/#**from-and-**to
+ destination: /docs/grafana//dashboards/variables/add-template-variables/#__from-and-__to
- pattern: /docs/grafana-cloud/
- destination: /docs/grafana-cloud/visualizations/dashboards/variables/add-template-variables/#**from-and-**to
+ destination: /docs/grafana-cloud/visualizations/dashboards/variables/add-template-variables/#__from-and-__to
heatmap:
- pattern: /docs/grafana/
destination: /docs/grafana//panels-visualizations/visualizations/heatmap/
@@ -253,13 +253,17 @@ If you want to add all of the current dashboard's variables to the URL, then use
1. Hover over any part of the panel to display the menu icon in the upper-right corner.
1. Click the menu icon and select **Edit** to open the panel editor.
1. In the panel edit pane, scroll down to the **Data links** section and expand it.
-1. Click **Add link**.
-1. In the dialog box that opens, enter a **Title**. This is a human-readable label for the link, which will be displayed in the UI.
-1. Enter the **URL** or variable to which you want to link.
+1. Click **+ Add link**.
+1. In the dialog box that opens, enter a **Title**. This is a human-readable label for the link displayed in the UI. This is a required field.
+1. Enter the **URL** or variable to which you want to link. This is a required field.
To add a data link variable, click in the **URL** field and enter `$` or press Ctrl+Space or Cmd+Space to see a list of available variables.
1. If you want the link to open in a new tab, toggle the **Open in a new tab** switch.
+1. If you want the data link to open with a single click, toggle the **One click** switch.
+
+ Only one data link can have **One click** enabled at a time. **One click** is only supported for some visualizations.
+
1. Click **Save** to save changes and close the dialog box.
1. Click **Save dashboard**.
1. Click **Back to dashboard** and then **Exit edit**.
diff --git a/docs/sources/panels-visualizations/visualizations/bar-chart/index.md b/docs/sources/panels-visualizations/visualizations/bar-chart/index.md
index 3d7f982d554..c9aaf77688b 100644
--- a/docs/sources/panels-visualizations/visualizations/bar-chart/index.md
+++ b/docs/sources/panels-visualizations/visualizations/bar-chart/index.md
@@ -201,7 +201,7 @@ You can set standard min/max options to define hard limits of the Y-axis. For mo
### Data links
-{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}}
+{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="" >}}
### Value mappings
diff --git a/docs/sources/panels-visualizations/visualizations/bar-gauge/index.md b/docs/sources/panels-visualizations/visualizations/bar-gauge/index.md
index f4059970634..cc70b2bbb20 100644
--- a/docs/sources/panels-visualizations/visualizations/bar-gauge/index.md
+++ b/docs/sources/panels-visualizations/visualizations/bar-gauge/index.md
@@ -213,7 +213,7 @@ This option only applies when bar size is set to manual.
## Data links
-{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}}
+{{< docs/shared lookup="visualizations/datalink-options-1.md" source="grafana" version="" >}}
## Value mappings
diff --git a/docs/sources/panels-visualizations/visualizations/candlestick/index.md b/docs/sources/panels-visualizations/visualizations/candlestick/index.md
index ee51937afc9..c673509a446 100644
--- a/docs/sources/panels-visualizations/visualizations/candlestick/index.md
+++ b/docs/sources/panels-visualizations/visualizations/candlestick/index.md
@@ -138,7 +138,7 @@ The candlestick visualization is based on the time series visualization. It can
## Data links
-{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}}
+{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="" >}}
## Thresholds
diff --git a/docs/sources/panels-visualizations/visualizations/canvas/index.md b/docs/sources/panels-visualizations/visualizations/canvas/index.md
index b31b8018902..3313fb3b225 100644
--- a/docs/sources/panels-visualizations/visualizations/canvas/index.md
+++ b/docs/sources/panels-visualizations/visualizations/canvas/index.md
@@ -330,14 +330,14 @@ Use the following options to control the border of the canvas:
The following options allow you to control the appearance of the element you've selected. To access an element so that you can edit it, expand the **Layer** section and select the desired element.
-| Option | Description |
-| ------------------------------------------- | ----------------------------------------------------------------------------------------------- |
-| [Element type](#element-type) | Change the selected element type. |
-| [Element](#element) | Control the appearance of text on the element. This section is named based on the element type. |
-| [Layout](#layout) | Control the placement of elements on the canvas. |
-| [Background (element)](#background-element) | Set the background of the element. |
-| [Border (element)](#border-element) | Set the border of the element. |
-| [Data links](#data-links) | Configure data links for elements. |
+| Option | Description |
+| ------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
+| [Element type](#element-type) | Change the selected element type. |
+| [Element](#element) | Control the appearance of text on the element. This section is named based on the element type. |
+| [Layout](#layout) | Control the placement of elements on the canvas. |
+| [Background (element)](#background-element) | Set the background of the element. |
+| [Border (element)](#border-element) | Set the border of the element. |
+| [Data links and actions](#data-links-and-actions) | Configure data links and actions for elements. |
#### Element type
@@ -405,40 +405,66 @@ Use the following options to set the border of the element:
- **Color** - Set the border color. This option is only displayed when the border width is greater than zero.
- **Radius** - Add rounded corners to the element border and control the degree of curve.
-#### Data links
+#### Data links and actions
-Canvases support [data links](ref:data-links) for all elements except drone and button elements. You can add a data link by following these steps:
+Canvases support [data links](ref:data-links) and actions for all elements.
-1. Enable inline editing.
-1. Click the element you to which you want to add the data link.
-1. In either the inline editor or panel editor, expand the **Selected element** editor.
-1. Scroll down to the **Data links** section and expand it.
-1. Click **Add link**.
-1. In the dialog box that opens, enter a **Title**. This is a human-readable label for the link, which will be displayed in the UI.
-1. Enter the **URL** or variable to which you want to link.
+If you add multiple data links or actions to an element, you can control the order in which they appear in the element tooltip.
+To do this, click and drag the link or action to the desired position.
- To add a data link variable, click in the **URL** field and enter `$` or press Ctrl+Space or Cmd+Space to see a list of available variables.
+The following tasks describe how to configure data links and actions.
-1. If you want the link to open in a new tab, toggle the **Open in a new tab** switch.
-1. Click **Save** to save changes and close the dialog box.
-1. Disable inline editing.
-
-If you add multiple data links, you can control the order in which they appear in the visualization. To do this, click and drag the data link to the desired position.
-
-##### One-click data link
-
-You can configure a canvas data link to open with a single click on the element. To enable this feature, follow these steps:
+{{< tabs >}}
+{{< tab-content name="Add data links" >}}
+To add a data link, follow these steps:
1. Enable inline editing.
1. Click the element to which you want to add the data link.
1. In either the inline editor or panel editor, expand the **Selected element** editor.
-1. Scroll down to the **Data links** section and expand it.
-1. In the **One-click** section, choose **Link**.
+1. Scroll down to the **Data links and actions** section and expand it.
+1. Click **+ Add link**.
+1. In the dialog box that opens, enter a **Title**.
+
+ This is a human-readable label for the link displayed in the UI. This is a required field.
+
+1. Enter the **URL** or variable to which you want to link.
+
+ To add a data link variable, click in the **URL** field and enter `$` or press Ctrl+Space or Cmd+Space to see a list of available variables. This is a required field.
+
+1. If you want the link to open in a new tab, toggle the **Open in a new tab** switch.
+1. If you want the data link to open with a single click on the element, toggle the **One click** switch.
+
+ Only one data link or action can have **One click** enabled at a time.
+
+1. Click **Save** to save changes and close the dialog box.
1. Disable inline editing.
+ {{< /tab-content >}}
+ {{< tab-content name="Add actions" >}}
+ To add an action, by follow these steps:
-The first data link in the list will be configured as your one-click data link. If you want to change the one-click data link, simply drag the desired data link to the top of the list.
+1. Enable inline editing.
+1. Click the element to which you want to add the data link.
+1. In either the inline editor or panel editor, expand the **Selected element** editor.
+1. Scroll down to the **Data links and actions** section and expand it.
+1. Click **+ Add action**.
+1. In the dialog box that opens, set the action options:
-{{< video-embed src="/media/docs/grafana/panels-visualizations/canvas-one-click-datalink-.mp4" >}}
+ | Option | Description |
+ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | Title | A human-readable label for the action that's displayed in the UI. |
+ | Confirmation message | A descriptive prompt to confirm or cancel the action. |
+ | One click | If you want the action to be triggered by a single click on the element, toggle the switch.
Only one data link or action can have **One click** enabled at a time. |
+ | Method | Select from **POST**, **PUT**, or **GET**. |
+ | URL | The request URL or variable to which you want to link.
To add a variable, click in the **URL** field and enter `$` or press Ctrl+Space or Cmd+Space to see a list of available variables. |
+ | Query parameters | **Key** and **Value** pairs. Click the **+** icon to add as many key/value pairs as you need. |
+ | Headers | Comprised of **Key** and **Value** pairs and a **Content-Type**.
Click the **+** icon to add as many key/value pairs as you need. |
+ | Content-Type | Select from the following: **application/json**, **text/plain**, **application/XML**, and **application/x-www-form-urlencoded**. |
+ | Body | The body of the request. |
+
+1. Click **Save** to save changes and close the dialog box.
+1. Disable inline editing.
+ {{< /tab-content >}}
+ {{< /tabs >}}
### Selected connection options
diff --git a/docs/sources/panels-visualizations/visualizations/gauge/index.md b/docs/sources/panels-visualizations/visualizations/gauge/index.md
index bfcd1c89549..fb6b224a12e 100644
--- a/docs/sources/panels-visualizations/visualizations/gauge/index.md
+++ b/docs/sources/panels-visualizations/visualizations/gauge/index.md
@@ -173,7 +173,7 @@ Adjust the sizes of the gauge text.
### Data links
-{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}}
+{{< docs/shared lookup="visualizations/datalink-options-1.md" source="grafana" version="" >}}
### Value mappings
diff --git a/docs/sources/panels-visualizations/visualizations/pie-chart/index.md b/docs/sources/panels-visualizations/visualizations/pie-chart/index.md
index bb1d2cd02aa..83efab500f9 100644
--- a/docs/sources/panels-visualizations/visualizations/pie-chart/index.md
+++ b/docs/sources/panels-visualizations/visualizations/pie-chart/index.md
@@ -176,7 +176,7 @@ Use these settings to define how the legend appears in your visualization. For m
### Data links
-{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}}
+{{< docs/shared lookup="visualizations/datalink-options-1.md" source="grafana" version="" >}}
### Value mappings
diff --git a/docs/sources/panels-visualizations/visualizations/stat/index.md b/docs/sources/panels-visualizations/visualizations/stat/index.md
index eb989ae6d4e..8eca8f63b29 100644
--- a/docs/sources/panels-visualizations/visualizations/stat/index.md
+++ b/docs/sources/panels-visualizations/visualizations/stat/index.md
@@ -174,7 +174,7 @@ Adjust the sizes of the gauge text.
### Data links
-{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}}
+{{< docs/shared lookup="visualizations/datalink-options-1.md" source="grafana" version="" >}}
### Value mappings
diff --git a/docs/sources/panels-visualizations/visualizations/state-timeline/index.md b/docs/sources/panels-visualizations/visualizations/state-timeline/index.md
index a6916eb1f83..cc861506203 100644
--- a/docs/sources/panels-visualizations/visualizations/state-timeline/index.md
+++ b/docs/sources/panels-visualizations/visualizations/state-timeline/index.md
@@ -159,7 +159,7 @@ The **Page size** option lets you paginate the state timeline visualization to l
### Data links
-{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}}
+{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="" >}}
### Value mappings
diff --git a/docs/sources/panels-visualizations/visualizations/status-history/index.md b/docs/sources/panels-visualizations/visualizations/status-history/index.md
index 357461f3382..998ff72e251 100644
--- a/docs/sources/panels-visualizations/visualizations/status-history/index.md
+++ b/docs/sources/panels-visualizations/visualizations/status-history/index.md
@@ -145,7 +145,7 @@ Controls the opacity of state regions.
## Data links
-{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}}
+{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="" >}}
## Value mappings
diff --git a/docs/sources/panels-visualizations/visualizations/table/index.md b/docs/sources/panels-visualizations/visualizations/table/index.md
index 42635aa733f..c53a0f7adff 100644
--- a/docs/sources/panels-visualizations/visualizations/table/index.md
+++ b/docs/sources/panels-visualizations/visualizations/table/index.md
@@ -364,7 +364,7 @@ If you want to apply this setting to only some fields instead of all fields, you
### Data links
-{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}}
+{{< docs/shared lookup="visualizations/datalink-options-1.md" source="grafana" version="" >}}
### Value mappings
diff --git a/docs/sources/panels-visualizations/visualizations/time-series/index.md b/docs/sources/panels-visualizations/visualizations/time-series/index.md
index e54509d5aeb..2031d0db76a 100644
--- a/docs/sources/panels-visualizations/visualizations/time-series/index.md
+++ b/docs/sources/panels-visualizations/visualizations/time-series/index.md
@@ -364,7 +364,7 @@ Set the position of the bar relative to a data point. In the examples below, **S
### Data links
-{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}}
+{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="" >}}
### Value mappings
diff --git a/docs/sources/panels-visualizations/visualizations/trend/index.md b/docs/sources/panels-visualizations/visualizations/trend/index.md
index 7c8900b0f4f..49032640568 100644
--- a/docs/sources/panels-visualizations/visualizations/trend/index.md
+++ b/docs/sources/panels-visualizations/visualizations/trend/index.md
@@ -62,7 +62,7 @@ For example, you could represent engine power and torque versus speed where spee
## Data links
-{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}}
+{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="" >}}
## Thresholds
diff --git a/docs/sources/panels-visualizations/visualizations/xy-chart/index.md b/docs/sources/panels-visualizations/visualizations/xy-chart/index.md
index 1e3f4d341dd..8e25e2e2dfa 100644
--- a/docs/sources/panels-visualizations/visualizations/xy-chart/index.md
+++ b/docs/sources/panels-visualizations/visualizations/xy-chart/index.md
@@ -310,7 +310,7 @@ To learn more, refer to [Configure standard options](ref:configure-standard-opti
## Data links
-{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}}
+{{< docs/shared lookup="visualizations/datalink-options-2.md" source="grafana" version="" >}}
## Field overrides
diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md
index 4805b42a1a7..e0b265a30d1 100644
--- a/docs/sources/setup-grafana/configure-grafana/_index.md
+++ b/docs/sources/setup-grafana/configure-grafana/_index.md
@@ -1821,7 +1821,7 @@ The timeout string is a possibly signed sequence of decimal numbers, followed by
#### `max_attempts`
-Sets a maximum number of times Grafana attempts to evaluate an alert rule before giving up on that evaluation. The default value is `1`.
+Sets a maximum number of times Grafana attempts to evaluate an alert rule before giving up on that evaluation. The default value is `3`.
#### `min_interval`
diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md
index 1be5e2d993e..0a192c80baf 100644
--- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md
+++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md
@@ -113,8 +113,26 @@ Sign in to Grafana and navigate to **Administration > Authentication > Configure
1. If you wish to [map user information from SAML assertions]({{< relref "../saml#assertion-mapping" >}}), complete the **Assertion attributes mappings** section.
- You also need to configure the **Groups attribute** field if you want to use group synchronization. Group sync allows you to automatically map users to Grafana teams or role-based access control roles based on their SAML group membership.
- To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation.
+If Azure is the Identity Provider over SAML there are caveats for the assertion attribute mappings. Due to how Azure interprets these attributes the full URL will need to be entered in the corresponding fields within the UI, which should match the URLs from the metadata XML. There are differences depending on whether it's a Role or Group claim vs other assertions which Microsoft has [documented](https://learn.microsoft.com/en-us/entra/identity-platform/reference-claims-customization#table-2-saml-restricted-claim-set).
+
+Group and Role:
+
+```
+http://schemas.microsoft.com/ws/2008/06/identity/claims/role
+http://schemas.microsoft.com/ws/2008/06/identity/claims/groups
+http://schemas.microsoft.com/identity/claims/displayname
+```
+
+Other Assertions:
+
+```
+http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
+```
+
+
+
+You also need to configure the **Groups attribute** field if you want to use group synchronization. Group sync allows you to automatically map users to Grafana teams or role-based access control roles based on their SAML group membership.
+To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation.
1. If you want to automatically assign users' roles based on their SAML roles, complete the **Role mapping** section.
diff --git a/docs/sources/setup-grafana/configure-security/configure-team-sync.md b/docs/sources/setup-grafana/configure-security/configure-team-sync.md
index f5d50cff311..89665525574 100644
--- a/docs/sources/setup-grafana/configure-security/configure-team-sync.md
+++ b/docs/sources/setup-grafana/configure-security/configure-team-sync.md
@@ -27,16 +27,15 @@ This mechanism allows Grafana to remove an existing synchronized user from a tea
## Supported providers
-- [Auth Proxy]({{< relref "./configure-authentication/auth-proxy#team-sync-enterprise-only" >}})
-- [Azure AD](https://grafana.com/docs/grafana//configure-authentication/azuread#group-sync-enterprise-only)
-- [Azure AD](https://grafana.com/docs/grafana//configure-security/configure-authentication/azuread#group-sync-enterprise-only)
-- [Generic OAuth integration](https://grafana.com/docs/grafana//configure-security/configure-authentication/generic-oauth#configure-group-synchronization)
-- [GitHub OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/github#configure-group-synchronization)
-- [GitLab OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/gitlab#configure-group-synchronization)
-- [Google OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/google#configure-group-synchronization)
-- [LDAP](https://grafana.com/docs/grafana//configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization)
-- [Okta](https://grafana.com/docs/grafana//configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only)
-- [SAML](https://grafana.com/docs/grafana//configure-security/configure-authentication/saml#configure-group-synchronization)
+- [Auth Proxy](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/auth-proxy/#team-sync-enterprise-only)
+- [Azure AD](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/azuread#group-sync-enterprise-only)
+- [Generic OAuth integration](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/generic-oauth#configure-group-synchronization)
+- [GitHub OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/github#configure-group-synchronization)
+- [GitLab OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/gitlab#configure-group-synchronization)
+- [Google OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/google#configure-group-synchronization)
+- [LDAP](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization)
+- [Okta](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only)
+- [SAML](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/saml#configure-group-synchronization)
## Synchronize a Grafana team with an external group
diff --git a/docs/sources/shared/visualizations/datalink-options-1.md b/docs/sources/shared/visualizations/datalink-options-1.md
new file mode 100644
index 00000000000..debfdcccbf9
--- /dev/null
+++ b/docs/sources/shared/visualizations/datalink-options-1.md
@@ -0,0 +1,17 @@
+---
+title: Data link options
+comments: |
+ This file is used in the following visualizations: bar gauge, gauge, pie chart, stat, table
+---
+
+Data links allow you to link to other panels, dashboards, and external resources while maintaining the context of the source panel.
+You can create links that include the series name or even the value under the cursor.
+To learn more, refer to [Configure data links](../../configure-data-links/).
+
+For each data link, set the following options:
+
+- **Title**
+- **URL**
+- **Open in new tab**
+
+Data links for this visualization don't include the **One click** switch, however, if there's only one data link configured, that data link has single-click functionality. If multiple data links are configured, then clicking the visualization opens a menu that displays all the data links.
diff --git a/docs/sources/shared/visualizations/datalink-options-2.md b/docs/sources/shared/visualizations/datalink-options-2.md
new file mode 100644
index 00000000000..ec8502a1ae5
--- /dev/null
+++ b/docs/sources/shared/visualizations/datalink-options-2.md
@@ -0,0 +1,16 @@
+---
+title: Data link options
+comments: |
+ This file is used in the following visualizations: bar chart, candlestick, state timeline, status history, time series, trend, xy chart
+---
+
+Data links allow you to link to other panels, dashboards, and external resources while maintaining the context of the source panel. You can create links that include the series name or even the value under the cursor.
+
+For each data link, set the following options:
+
+- **Title**
+- **URL**
+- **Open in new tab**
+- **One click** - Opens the data link with a single click. Only one data link can have **One click** enabled at a time.
+
+To learn more, refer to [Configure data links](../../configure-data-links/).
diff --git a/docs/sources/shared/visualizations/datalink-options.md b/docs/sources/shared/visualizations/datalink-options.md
index e3d77551cf5..6e567cfd464 100644
--- a/docs/sources/shared/visualizations/datalink-options.md
+++ b/docs/sources/shared/visualizations/datalink-options.md
@@ -1,7 +1,7 @@
---
title: Data link options
comments: |
- This file is used in the following visualizations: bar chart, bar gauge, candlestick, gauge, geomap, heatmap, histogram, stat, state timeline, status history, table, time series, trend, xy chart
+ This file is used in the following visualizations: geomap, heatmap, histogram
---
Data links allow you to link to other panels, dashboards, and external resources while maintaining the context of the source panel. You can create links that include the series name or even the value under the cursor.
diff --git a/docs/sources/upgrade-guide/when-to-upgrade/index.md b/docs/sources/upgrade-guide/when-to-upgrade/index.md
index 052107861c4..31d6385ef9f 100644
--- a/docs/sources/upgrade-guide/when-to-upgrade/index.md
+++ b/docs/sources/upgrade-guide/when-to-upgrade/index.md
@@ -99,6 +99,7 @@ Here is an overview of projected version support through 2024:
| 11.2 | August 2024 | May 2025 |
| 11.3 | October 2024 | July 2025 |
| 11.4 | December 2024 | September 2025 |
+| 11.5 | January 2025 | October 2025 |
{{< admonition type="note" >}}
Grafana 9.5.x was the last supported minor for the 9.0 major release and is no longer supported as of July 2024.
diff --git a/e2e/old-arch/various-suite/solo-route.spec.ts b/e2e/old-arch/various-suite/solo-route.spec.ts
index 9717baf41bd..415257ead7c 100644
--- a/e2e/old-arch/various-suite/solo-route.spec.ts
+++ b/e2e/old-arch/various-suite/solo-route.spec.ts
@@ -25,7 +25,7 @@ describe('Solo Route', () => {
it('Can view solo repeated panel in scenes', () => {
// open Panel Tests - Graph NG
e2e.pages.SoloPanel.visit(
- 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true'
+ 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-0&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server=A').should('exist');
@@ -38,7 +38,7 @@ describe('Solo Route', () => {
'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true'
);
- e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist');
+ e2e.components.Panels.Panel.title('server = B, pod = Rob').should('exist');
cy.contains('uplot-main-div').should('not.exist');
});
});
diff --git a/e2e/various-suite/solo-route.spec.ts b/e2e/various-suite/solo-route.spec.ts
index 9717baf41bd..c7f90f8cd36 100644
--- a/e2e/various-suite/solo-route.spec.ts
+++ b/e2e/various-suite/solo-route.spec.ts
@@ -22,15 +22,15 @@ describe('Solo Route', () => {
cy.contains('uplot-main-div').should('not.exist');
});
- it('Can view solo repeated panel in scenes', () => {
+ /*it('Can view solo repeated panel in scenes', () => {
// open Panel Tests - Graph NG
e2e.pages.SoloPanel.visit(
- 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true'
+ 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-0&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server=A').should('exist');
cy.contains('uplot-main-div').should('not.exist');
- });
+ });*/
it('Can view solo in repeated row and panel in scenes', () => {
// open Panel Tests - Graph NG
@@ -38,7 +38,7 @@ describe('Solo Route', () => {
'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true'
);
- e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist');
+ e2e.components.Panels.Panel.title('server = B, pod = Rob').should('exist');
cy.contains('uplot-main-div').should('not.exist');
});
});
diff --git a/go.mod b/go.mod
index d758105feae..e2845126aa1 100644
--- a/go.mod
+++ b/go.mod
@@ -111,7 +111,7 @@ require (
github.com/influxdata/influxql v1.4.0 // @grafana/partner-datasources
github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // @grafana/grafana-app-platform-squad
github.com/jmespath-community/go-jmespath v1.1.1 // @grafana/identity-access-team
- github.com/jmespath/go-jmespath v0.4.0 // indirect; // @grafana/grafana-backend-group
+ github.com/jmespath/go-jmespath v0.4.0 // // @grafana/grafana-backend-group
github.com/jmoiron/sqlx v1.3.5 // @grafana/grafana-backend-group
github.com/json-iterator/go v1.1.12 // @grafana/grafana-backend-group
github.com/lib/pq v1.10.9 // @grafana/grafana-backend-group
@@ -221,6 +221,8 @@ require (
github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-search-and-storage
)
+require github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend
+
require (
cel.dev/expr v0.19.0 // indirect
cloud.google.com/go v0.116.0 // indirect
diff --git a/go.sum b/go.sum
index f00f8876d26..dea3dbcf891 100644
--- a/go.sum
+++ b/go.sum
@@ -1310,6 +1310,8 @@ github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:9wScpmSP5A3Bk
github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:56xuuqnHyryaerycW3BfssRdxQstACi0Epw/yC5E2xM=
github.com/go-zookeeper/zk v1.0.4 h1:DPzxraQx7OrPyXq2phlGlNSIyWEsAox0RJmjTseMV6I=
github.com/go-zookeeper/zk v1.0.4/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
+github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA=
+github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
@@ -1544,6 +1546,8 @@ github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447 h1:jxJJ5z0GxqhWFbQU
github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447/go.mod h1:IxsY6mns6Q5sAnWcrptrgUrSglTZJXH/kXr9nbpb/9I=
github.com/grafana/gomemcache v0.0.0-20240805133030-fdaf6a95408e h1:UlEET0InuoFautfaFp8lDrNF7rPHYXuBMrzwWx9XqFY=
github.com/grafana/gomemcache v0.0.0-20240805133030-fdaf6a95408e/go.mod h1:IGRj8oOoxwJbHBYl1+OhS9UjQR0dv6SQOep7HqmtyFU=
+github.com/grafana/grafana-api-golang-client v0.27.0 h1:zIwMXcbCB4n588i3O2N6HfNcQogCNTd/vPkEXTr7zX8=
+github.com/grafana/grafana-api-golang-client v0.27.0/go.mod h1:uNLZEmgKtTjHBtCQMwNn3qsx2mpMb8zU+7T4Xv3NR9Y=
github.com/grafana/grafana-app-sdk v0.31.0 h1:/mFCcx+YqG8cWAi9hePDJQxIdtXDClDIDRgZwHkksFk=
github.com/grafana/grafana-app-sdk v0.31.0/go.mod h1:Xw00NL7qpRLo5r3Gn48Bl1Xn2n4eUDI5pYf/wMufKWs=
github.com/grafana/grafana-app-sdk/logging v0.30.0 h1:K/P/bm7Cp7Di4tqIJ3EQz2+842JozQGRaz62r95ApME=
diff --git a/go.work.sum b/go.work.sum
index 070810818ab..89477e05e8a 100644
--- a/go.work.sum
+++ b/go.work.sum
@@ -1456,6 +1456,7 @@ github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDs
github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
github.com/go-zookeeper/zk v1.0.3 h1:7M2kwOsc//9VeeFiPtf+uSJlVpU66x9Ba5+8XK7/TDg=
github.com/go-zookeeper/zk v1.0.3/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
+github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue
index da8be7ef3d5..bbf3cb24321 100644
--- a/kinds/dashboard/dashboard_kind.cue
+++ b/kinds/dashboard/dashboard_kind.cue
@@ -74,7 +74,7 @@ lineage: schemas: [{
// Version of the JSON schema, incremented each time a Grafana update brings
// changes to said schema.
- schemaVersion: uint16 | *39
+ schemaVersion: uint16 | *41
// Version of the dashboard, incremented each time the dashboard is updated.
version?: uint32
@@ -473,8 +473,6 @@ lineage: schemas: [{
hidden?: bool | *false
// Interval options available in the refresh picker dropdown.
refresh_intervals?: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
- // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.
- time_options?: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
// Quick ranges for time picker.
quick_ranges?: [...#TimeOption]
// Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.
diff --git a/package.json b/package.json
index 189fc00835e..7e889ddedde 100644
--- a/package.json
+++ b/package.json
@@ -56,8 +56,7 @@
"ci:test-frontend": "yarn run test:ci",
"i18n:stats": "node ./scripts/cli/reportI18nStats.mjs",
"betterer": "betterer --tsconfig ./scripts/cli/tsconfig.json",
- "betterer:merge": "betterer merge --tsconfig ./scripts/cli/tsconfig.json",
- "betterer:stats": "node ./scripts/cli/reportBettererStats.mjs",
+ "betterer:stats": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/reportBettererStats.ts",
"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'",
@@ -276,8 +275,8 @@
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/saga-icons": "workspace:*",
- "@grafana/scenes": "5.42.0",
- "@grafana/scenes-react": "5.42.0",
+ "@grafana/scenes": "6.0.2",
+ "@grafana/scenes-react": "6.0.2",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
@@ -381,8 +380,8 @@
"react-redux": "9.2.0",
"react-resizable": "3.0.5",
"react-responsive-carousel": "^3.2.23",
- "react-router": "5.3.3",
- "react-router-dom": "5.3.3",
+ "react-router": "5.3.4",
+ "react-router-dom": "5.3.4",
"react-router-dom-v5-compat": "^6.26.1",
"react-select": "5.10.0",
"react-split-pane": "0.1.92",
diff --git a/packages/grafana-data/src/field/overrides/processors.ts b/packages/grafana-data/src/field/overrides/processors.ts
index 8c8542fd6ff..e80a73de866 100644
--- a/packages/grafana-data/src/field/overrides/processors.ts
+++ b/packages/grafana-data/src/field/overrides/processors.ts
@@ -50,7 +50,9 @@ export interface SliderFieldConfigSettings {
ariaLabelForHandle?: string;
}
-export interface DataLinksFieldConfigSettings {}
+export interface DataLinksFieldConfigSettings {
+ showOneClick?: boolean;
+}
export const dataLinksOverrideProcessor = (
value: any,
diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts
index 76195d7d8c0..6aa49febd08 100644
--- a/packages/grafana-data/src/index.ts
+++ b/packages/grafana-data/src/index.ts
@@ -549,6 +549,7 @@ export {
type PluginExtensionLink,
type PluginExtensionComponent,
type PluginExtensionConfig,
+ type PluginExtensionFunction,
type PluginExtensionLinkConfig,
type PluginExtensionComponentConfig,
type PluginExtensionEventHelpers,
@@ -559,6 +560,7 @@ export {
type PluginExtensionExposedComponentConfig,
type PluginExtensionAddedComponentConfig,
type PluginExtensionAddedLinkConfig,
+ type PluginExtensionAddedFunctionConfig,
} from './types/pluginExtensions';
export {
type ScopeDashboardBindingSpec,
diff --git a/packages/grafana-data/src/themes/createComponents.ts b/packages/grafana-data/src/themes/createComponents.ts
index 70dbcd67cf5..e810a94b86e 100644
--- a/packages/grafana-data/src/themes/createComponents.ts
+++ b/packages/grafana-data/src/themes/createComponents.ts
@@ -80,7 +80,7 @@ export function createComponents(colors: ThemeColors, shadows: ThemeShadows): Th
input,
panel,
dropdown: {
- background: input.background,
+ background: colors.background.elevated,
},
tooltip: {
background: colors.background.elevated,
diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts
index 3b33452bd03..23e52f913b8 100644
--- a/packages/grafana-data/src/types/app.ts
+++ b/packages/grafana-data/src/types/app.ts
@@ -9,6 +9,7 @@ import {
PluginExtensionExposedComponentConfig,
PluginExtensionAddedComponentConfig,
PluginExtensionAddedLinkConfig,
+ PluginExtensionAddedFunctionConfig,
} from './pluginExtensions';
/**
@@ -60,6 +61,7 @@ export class AppPlugin extends GrafanaPlugin>;
@@ -113,6 +115,10 @@ export class AppPlugin extends GrafanaPlugin(linkConfig: PluginExtensionAddedLinkConfig) {
this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig);
@@ -125,6 +131,12 @@ export class AppPlugin extends GrafanaPlugin(addedFunctionConfig: PluginExtensionAddedFunctionConfig) {
+ this._addedFunctionConfigs.push(addedFunctionConfig);
+
+ return this;
+ }
+
exposeComponent(componentConfig: PluginExtensionExposedComponentConfig) {
this._exposedComponentConfigs.push(componentConfig as PluginExtensionExposedComponentConfig);
diff --git a/packages/grafana-data/src/types/fieldOverrides.ts b/packages/grafana-data/src/types/fieldOverrides.ts
index d96f22190bd..f2283c5956e 100644
--- a/packages/grafana-data/src/types/fieldOverrides.ts
+++ b/packages/grafana-data/src/types/fieldOverrides.ts
@@ -144,6 +144,7 @@ export enum FieldConfigProperty {
Thresholds = 'thresholds',
Mappings = 'mappings',
Links = 'links',
+ Actions = 'actions',
Color = 'color',
Filterable = 'filterable',
}
diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts
index ebb3da684f6..8e1be814c96 100644
--- a/packages/grafana-data/src/types/plugin.ts
+++ b/packages/grafana-data/src/types/plugin.ts
@@ -130,6 +130,8 @@ export interface PluginExtensions {
// The component extensions that the plugin registers
addedComponents: ExtensionInfo[];
+ addedFunctions: ExtensionInfo[];
+
// The link extensions that the plugin registers
addedLinks: ExtensionInfo[];
diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts
index fcbbecf5f41..5b10f80f143 100644
--- a/packages/grafana-data/src/types/pluginExtensions.ts
+++ b/packages/grafana-data/src/types/pluginExtensions.ts
@@ -14,6 +14,7 @@ import { RawTimeRange, TimeZone } from './time';
export enum PluginExtensionTypes {
link = 'link',
component = 'component',
+ function = 'function',
}
type PluginExtensionBase = {
@@ -36,7 +37,12 @@ export type PluginExtensionComponent = PluginExtensionBase & {
component: React.ComponentType;
};
-export type PluginExtension = PluginExtensionLink | PluginExtensionComponent;
+export type PluginExtensionFunction void> = PluginExtensionBase & {
+ type: PluginExtensionTypes.function;
+ fn: Signature;
+};
+
+export type PluginExtension = PluginExtensionLink | PluginExtensionComponent | PluginExtensionFunction;
// Objects used for registering extensions (in app plugins)
// --------------------------------------------------------
@@ -74,6 +80,17 @@ export type PluginExtensionAddedComponentConfig = PluginExtensionCon
*/
component: React.ComponentType;
};
+export type PluginExtensionAddedFunctionConfig = PluginExtensionConfigBase & {
+ /**
+ * The target extension points where the component will be added
+ */
+ targets: string | string[];
+
+ /**
+ * The function to be executed
+ */
+ fn: Signature;
+};
export type PluginAddedLinksConfigureFunc = (context: Readonly | undefined) =>
| Partial<{
diff --git a/packages/grafana-prometheus/src/dashboards/grafana_stats.json b/packages/grafana-prometheus/src/dashboards/grafana_stats.json
index 0131aa9bc40..292f93394f3 100644
--- a/packages/grafana-prometheus/src/dashboards/grafana_stats.json
+++ b/packages/grafana-prometheus/src/dashboards/grafana_stats.json
@@ -1178,8 +1178,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Grafana metrics",
diff --git a/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json b/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json
index 034663fe6f4..5a6fbdf8518 100644
--- a/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json
+++ b/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json
@@ -1394,8 +1394,7 @@
},
"timepicker": {
"now": true,
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "browser",
"title": "Prometheus 2.0 Stats",
diff --git a/packages/grafana-prometheus/src/dashboards/prometheus_stats.json b/packages/grafana-prometheus/src/dashboards/prometheus_stats.json
index 8a2764c5cb7..42ea6e7a4d5 100644
--- a/packages/grafana-prometheus/src/dashboards/prometheus_stats.json
+++ b/packages/grafana-prometheus/src/dashboards/prometheus_stats.json
@@ -825,8 +825,7 @@
},
"timepicker": {
"now": true,
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "browser",
"title": "Prometheus Stats",
diff --git a/packages/grafana-prometheus/src/querybuilder/shared/OperationInfoButton.tsx b/packages/grafana-prometheus/src/querybuilder/shared/OperationInfoButton.tsx
index 69f26d84865..b576188a581 100644
--- a/packages/grafana-prometheus/src/querybuilder/shared/OperationInfoButton.tsx
+++ b/packages/grafana-prometheus/src/querybuilder/shared/OperationInfoButton.tsx
@@ -94,8 +94,8 @@ const getStyles = (theme: GrafanaTheme2) => {
return {
docBox: css({
overflow: 'hidden',
- background: theme.colors.background.primary,
- border: `1px solid ${theme.colors.border.strong}`,
+ background: theme.colors.background.elevated,
+ border: `1px solid ${theme.colors.border.weak}`,
boxShadow: theme.shadows.z3,
maxWidth: '600px',
padding: theme.spacing(1),
diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts
index 3e02e0d2863..5e8892c5cc7 100644
--- a/packages/grafana-runtime/src/services/index.ts
+++ b/packages/grafana-runtime/src/services/index.ts
@@ -22,6 +22,8 @@ export {
type UsePluginExtensions,
type UsePluginExtensionsResult,
type UsePluginComponentResult,
+ type UsePluginFunctionsOptions,
+ type UsePluginFunctionsResult,
} from './pluginExtensions/getPluginExtensions';
export {
setPluginExtensionsHook,
@@ -33,6 +35,7 @@ export {
export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent';
export { setPluginComponentsHook, usePluginComponents } from './pluginExtensions/usePluginComponents';
export { setPluginLinksHook, usePluginLinks } from './pluginExtensions/usePluginLinks';
+export { setPluginFunctionsHook, usePluginFunctions } from './pluginExtensions/usePluginFunctions';
export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils';
export { setCurrentUser } from './user';
diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts
index be151b05858..2f70132d5fe 100644
--- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts
+++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts
@@ -1,4 +1,9 @@
-import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } from '@grafana/data';
+import type {
+ PluginExtension,
+ PluginExtensionLink,
+ PluginExtensionComponent,
+ PluginExtensionFunction,
+} from '@grafana/data';
import { isPluginExtensionComponent, isPluginExtensionLink } from './utils';
@@ -52,6 +57,16 @@ export type UsePluginLinksResult = {
links: PluginExtensionLink[];
};
+export type UsePluginFunctionsOptions = {
+ extensionPointId: string;
+ limitPerPlugin?: number;
+};
+
+export type UsePluginFunctionsResult = {
+ isLoading: boolean;
+ functions: Array>;
+};
+
let singleton: GetPluginExtensions | undefined;
export function setPluginExtensionGetter(instance: GetPluginExtensions): void {
diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts
new file mode 100644
index 00000000000..1eb86b70e14
--- /dev/null
+++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts
@@ -0,0 +1,20 @@
+import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from './getPluginExtensions';
+
+export type UsePluginFunctions = (options: UsePluginFunctionsOptions) => UsePluginFunctionsResult;
+
+let singleton: UsePluginFunctions | undefined;
+
+export function setPluginFunctionsHook(hook: UsePluginFunctions): void {
+ // We allow overriding the registry in tests
+ if (singleton && process.env.NODE_ENV !== 'test') {
+ throw new Error('setUsePluginFunctionsHook() function should only be called once, when Grafana is starting.');
+ }
+ singleton = hook;
+}
+
+export function usePluginFunctions(options: UsePluginFunctionsOptions): UsePluginFunctionsResult {
+ if (!singleton) {
+ throw new Error('usePluginFunctions(options) can only be used after the Grafana instance has started.');
+ }
+ return singleton(options) as UsePluginFunctionsResult;
+}
diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts
index e200106086a..fbaa9c8f09b 100644
--- a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts
+++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts
@@ -681,17 +681,12 @@ export interface TimePickerConfig {
* Interval options available in the refresh picker dropdown.
*/
refresh_intervals?: Array;
- /**
- * Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.
- */
- time_options?: Array;
}
export const defaultTimePickerConfig: Partial = {
hidden: false,
quick_ranges: [],
refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
- time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'],
};
/**
@@ -1205,7 +1200,7 @@ export const defaultDashboard: Partial = {
graphTooltip: DashboardCursorSync.Off,
links: [],
panels: [],
- schemaVersion: 39,
+ schemaVersion: 41,
tags: [],
timezone: 'browser',
};
diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
index 4b983ddf7ae..f9763bb219b 100644
--- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
+++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
@@ -6,10 +6,7 @@ import (
DashboardV2Spec: {
// Title of dashboard.
- title: string
-
- // Description of dashboard.
- description?: string
+ annotations: [...AnnotationQueryKind]
// Configuration of dashboard cursor sync behavior.
// "Off" for no shared crosshair or tooltip (default).
@@ -17,6 +14,19 @@ DashboardV2Spec: {
// "Tooltip" for shared crosshair AND shared tooltip.
cursorSync: DashboardCursorSync
+ // Description of dashboard.
+ description?: string
+
+ // Whether a dashboard is editable or not.
+ editable?: bool | *true
+
+ elements: [ElementReference.name]: Element
+
+ layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind
+
+ // Links with references to other dashboards or external websites.
+ links: [...DashboardLink]
+
// When set to true, the dashboard will redraw panels at an interval matching the pixel width.
// This will keep data "moving left" regardless of the query refresh rate. This setting helps
// avoid dashboards presenting stale live data.
@@ -25,30 +35,20 @@ DashboardV2Spec: {
// When set to true, the dashboard will load all panels in the dashboard when it's loaded.
preload: bool
- // Whether a dashboard is editable or not.
- editable?: bool | *true
-
- // Links with references to other dashboards or external websites.
- links: [...DashboardLink]
+ // Plugins only. The version of the dashboard installed together with the plugin.
+ // This is used to determine if the dashboard should be updated when the plugin is updated.
+ revision?: uint16
// Tags associated with dashboard.
tags: [...string]
timeSettings: TimeSettingsSpec
+ // Title of dashboard.
+ title: string
+
// Configured template variables.
variables: [...VariableKind]
-
- elements: [ElementReference.name]: Element
-
- annotations: [...AnnotationQueryKind]
-
- layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind
-
-
- // Plugins only. The version of the dashboard installed together with the plugin.
- // This is used to determine if the dashboard should be updated when the plugin is updated.
- revision?: uint16
}
// Supported dashboard elements
@@ -447,6 +447,12 @@ QueryGroupKind: {
spec: QueryGroupSpec
}
+TimeRangeOption: {
+ display: string | *"Last 6 hours"
+ from: string | *"now-6h"
+ to: string | *"now"
+}
+
// Time configuration
// It defines the default time config for the time picker, the refresh picker for the specific dashboard.
TimeSettingsSpec: {
@@ -463,7 +469,7 @@ TimeSettingsSpec: {
// Interval options available in the refresh picker dropdown.
autoRefreshIntervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] // v1: timepicker.refresh_intervals
// Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.
- quickRanges: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] // v1: timepicker.time_options , not exposed in the UI
+ quickRanges?: [...TimeRangeOption] // v1: timepicker.quick_ranges , not exposed in the UI
// Whether timepicker is visible or not.
hideTimepicker: bool // v1: timepicker.hidden
// Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday".
diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts
index 40b091f29dd..c12f1cc73f7 100644
--- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts
+++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts
@@ -15,7 +15,6 @@ export const handyTestingSchema: DashboardV2Spec = {
from: 'now-1h',
hideTimepicker: false,
nowDelay: '1m',
- quickRanges: [],
timezone: 'UTC',
to: 'now',
weekStart: 'monday',
diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts
index 83c733c6f60..1ad02256fe8 100644
--- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts
+++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts
@@ -5,49 +5,50 @@ import * as common from '@grafana/schema';
export interface DashboardV2Spec {
// Title of dashboard.
- title: string;
- // Description of dashboard.
- description?: string;
+ annotations: AnnotationQueryKind[];
// Configuration of dashboard cursor sync behavior.
// "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip.
cursorSync: DashboardCursorSync;
+ // Description of dashboard.
+ description?: string;
+ // Whether a dashboard is editable or not.
+ editable?: boolean;
+ elements: Record;
+ layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind;
+ // Links with references to other dashboards or external websites.
+ links: DashboardLink[];
// When set to true, the dashboard will redraw panels at an interval matching the pixel width.
// This will keep data "moving left" regardless of the query refresh rate. This setting helps
// avoid dashboards presenting stale live data.
liveNow?: boolean;
// When set to true, the dashboard will load all panels in the dashboard when it's loaded.
preload: boolean;
- // Whether a dashboard is editable or not.
- editable?: boolean;
- // Links with references to other dashboards or external websites.
- links: DashboardLink[];
- // Tags associated with dashboard.
- tags: string[];
- timeSettings: TimeSettingsSpec;
- // Configured template variables.
- variables: VariableKind[];
- elements: Record;
- annotations: AnnotationQueryKind[];
- layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind;
// Plugins only. The version of the dashboard installed together with the plugin.
// This is used to determine if the dashboard should be updated when the plugin is updated.
revision?: number;
+ // Tags associated with dashboard.
+ tags: string[];
+ timeSettings: TimeSettingsSpec;
+ // Title of dashboard.
+ title: string;
+ // Configured template variables.
+ variables: VariableKind[];
}
export const defaultDashboardV2Spec = (): DashboardV2Spec => ({
- title: "",
+ annotations: [],
cursorSync: "Off",
- preload: false,
editable: true,
+ elements: {},
+ layout: defaultGridLayoutKind(),
links: [],
+ preload: false,
tags: [],
timeSettings: defaultTimeSettingsSpec(),
+ title: "",
variables: [],
- elements: {},
- annotations: [],
- layout: defaultGridLayoutKind(),
});
// Supported dashboard elements
@@ -620,6 +621,18 @@ export const defaultQueryGroupKind = (): QueryGroupKind => ({
spec: defaultQueryGroupSpec(),
});
+export interface TimeRangeOption {
+ display: string;
+ from: string;
+ to: string;
+}
+
+export const defaultTimeRangeOption = (): TimeRangeOption => ({
+ display: "Last 6 hours",
+ from: "now-6h",
+ to: "now",
+});
+
// Time configuration
// It defines the default time config for the time picker, the refresh picker for the specific dashboard.
export interface TimeSettingsSpec {
@@ -638,8 +651,8 @@ export interface TimeSettingsSpec {
// v1: timepicker.refresh_intervals
autoRefreshIntervals: string[];
// Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.
- // v1: timepicker.time_options , not exposed in the UI
- quickRanges: string[];
+ // v1: timepicker.quick_ranges , not exposed in the UI
+ quickRanges?: TimeRangeOption[];
// Whether timepicker is visible or not.
// v1: timepicker.hidden
hideTimepicker: boolean;
@@ -668,17 +681,6 @@ export const defaultTimeSettingsSpec = (): TimeSettingsSpec => ({
"1h",
"2h",
"1d",
-],
- quickRanges: [
-"5m",
-"15m",
-"1h",
-"6h",
-"12h",
-"24h",
-"2d",
-"7d",
-"30d",
],
hideTimepicker: false,
fiscalYearStartMonth: 0,
diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json
index cee844a1b5d..eff1830e872 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -97,7 +97,7 @@
"react-i18next": "^15.0.0",
"react-inlinesvg": "4.1.5",
"react-loading-skeleton": "3.5.0",
- "react-router-dom": "5.3.3",
+ "react-router-dom": "5.3.4",
"react-router-dom-v5-compat": "^6.26.1",
"react-select": "5.10.0",
"react-table": "7.8.0",
diff --git a/packages/grafana-ui/src/components/Cascader/styles.ts b/packages/grafana-ui/src/components/Cascader/styles.ts
index 537bb8c4a7e..cef38b62dcc 100644
--- a/packages/grafana-ui/src/components/Cascader/styles.ts
+++ b/packages/grafana-ui/src/components/Cascader/styles.ts
@@ -72,8 +72,8 @@ export const getCascaderStyles = (theme: GrafanaTheme2) => ({
'.rc-cascader': {
'&-menus': {
overflow: 'hidden',
- background: theme.colors.background.canvas,
- border: `1px solid ${theme.colors.border.weak}`,
+ background: theme.colors.background.elevated,
+ border: `none`,
borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z3,
whiteSpace: 'nowrap',
@@ -128,7 +128,7 @@ export const getCascaderStyles = (theme: GrafanaTheme2) => ({
height: '192px',
listStyle: 'none',
margin: 0,
- padding: 0,
+ padding: theme.spacing(0.5),
borderRight: `1px solid ${theme.colors.border.weak}`,
overflow: 'auto',
@@ -140,6 +140,7 @@ export const getCascaderStyles = (theme: GrafanaTheme2) => ({
height: theme.spacing(4),
lineHeight: theme.spacing(4),
padding: theme.spacing(0, 4, 0, 2),
+ borderRadius: theme.shape.radius.default,
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx
index 471bb22fe5d..62f231975e3 100644
--- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx
+++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx
@@ -407,7 +407,7 @@ export const Combobox = (props: ComboboxProps) =>
})}
>
{isOpen && (
-
+
{!asyncError && (
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
@@ -416,6 +416,7 @@ export const Combobox = (props: ComboboxProps) =>
key={`${items[virtualRow.index].value}-${virtualRow.index}`}
data-index={virtualRow.index}
className={cx(
+ styles.optionBasic,
styles.option,
selectedItem && items[virtualRow.index].value === selectedItem.value && styles.optionSelected,
highlightedIndex === virtualRow.index && styles.optionFocused
diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx
index bee10de6783..5be7bc09098 100644
--- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx
+++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx
@@ -156,8 +156,8 @@ describe('MultiCombobox', () => {
await user.type(input, 'D');
await user.keyboard('{arrowdown}{enter}');
expect(onChange).toHaveBeenCalledWith([
- { value: 'a' },
- { value: 'c' },
+ { label: 'A', value: 'a' },
+ { label: 'C', value: 'c' },
{ label: 'D', value: 'D', description: 'Use custom value' },
]);
});
@@ -235,6 +235,19 @@ describe('MultiCombobox', () => {
await user.click(await screen.findByRole('option', { name: 'All' }));
expect(onChange).toHaveBeenCalledWith([]);
});
+
+ it('should keep label names on selected items when searching', async () => {
+ const options = [
+ { label: 'A', value: 'a' },
+ { label: 'B', value: 'b' },
+ { label: 'C', value: 'c' },
+ ];
+ render( );
+ const input = screen.getByRole('combobox');
+ await user.click(input);
+ await user.type(input, 'b');
+ expect(screen.getByText('A')).toBeInTheDocument();
+ });
});
describe('async', () => {
diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx
index 7b41ca2ee53..7d18074910a 100644
--- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx
+++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx
@@ -79,8 +79,8 @@ export const MultiCombobox = (props: MultiComboboxPro
return [];
}
- return getSelectedItemsFromValue(value, baseOptions);
- }, [value, baseOptions]);
+ return getSelectedItemsFromValue(value, typeof props.options !== 'function' ? props.options : baseOptions);
+ }, [value, props.options, baseOptions]);
const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti(
selectedItems,
@@ -344,7 +344,7 @@ export const MultiCombobox = (props: MultiComboboxPro
{...getMenuProps({ ref: floatingRef })}
>
{isOpen && (
-
+
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const startingNewGroup = isNewGroup(options[virtualRow.index], options[virtualRow.index - 1]);
diff --git a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts
index f0ea95cacb2..6c788b15cbd 100644
--- a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts
+++ b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts
@@ -46,6 +46,7 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
option: css({
padding: MENU_ITEM_PADDING,
cursor: 'pointer',
+ borderRadius: theme.shape.radius.default,
width: '100%',
'&:hover': {
background: theme.colors.action.hover,
diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx
index 99b15ea1697..2b5a6ee7fe1 100644
--- a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx
+++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx
@@ -9,7 +9,12 @@ type DataLinksInlineEditorProps = Omit,
getSuggestions: () => VariableSuggestion[];
};
-export const DataLinksInlineEditor = ({ links, getSuggestions, showOneClick, ...rest }: DataLinksInlineEditorProps) => (
+export const DataLinksInlineEditor = ({
+ links,
+ getSuggestions,
+ showOneClick = false,
+ ...rest
+}: DataLinksInlineEditorProps) => (
type="link" items={links} {...rest}>
{(item, index, onSave, onCancel) => (
)}
diff --git a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx
index 312d6e76029..8c50da47478 100644
--- a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx
+++ b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx
@@ -21,6 +21,8 @@ interface Props {
onError?: (error: Error) => void;
/** Callback error state is cleared due to recover props change */
onRecover?: () => void;
+ /** Default error logger - Faro by default */
+ errorLogger?: (error: Error) => void;
}
interface State {
@@ -35,7 +37,12 @@ export class ErrorBoundary extends PureComponent {
};
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
- faro?.api?.pushError(error);
+ const logger = this.props.errorLogger ?? faro?.api?.pushError;
+
+ if (logger) {
+ logger(error);
+ }
+
this.setState({ error, errorInfo });
if (this.props.onError) {
@@ -89,6 +96,8 @@ export interface ErrorBoundaryAlertProps {
/** Will re-render children after error if recover values changes */
dependencies?: unknown[];
+ /** Default error logger - Faro by default */
+ errorLogger?: (error: Error) => void;
}
export class ErrorBoundaryAlert extends PureComponent {
@@ -98,10 +107,10 @@ export class ErrorBoundaryAlert extends PureComponent {
};
render() {
- const { title, children, style, dependencies } = this.props;
+ const { title, children, style, dependencies, errorLogger } = this.props;
return (
-
+
{({ error, errorInfo }) => {
if (!errorInfo) {
return children;
diff --git a/packages/grafana-ui/src/components/Select/SelectMenu.tsx b/packages/grafana-ui/src/components/Select/SelectMenu.tsx
index b557b2e9a65..e19c8d87546 100644
--- a/packages/grafana-ui/src/components/Select/SelectMenu.tsx
+++ b/packages/grafana-ui/src/components/Select/SelectMenu.tsx
@@ -54,7 +54,7 @@ export const SelectMenu = ({
style={{ maxHeight }}
aria-label="Select options menu"
>
-
+
{toggleAllOptions && (
{
whiteSpace: 'nowrap',
cursor: 'pointer',
borderLeft: '2px solid transparent',
+ borderRadius: theme.shape.radius.default,
'&:hover': {
background: theme.colors.action.hover,
diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx
index c3c4a34eb64..5b79c42711f 100644
--- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx
+++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx
@@ -1,11 +1,13 @@
import { css } from '@emotion/css';
+import { useMemo } from 'react';
-import { ActionModel, Field, GrafanaTheme2, LinkModel } from '@grafana/data';
+import { ActionModel, Field, GrafanaTheme2, LinkModel, ThemeSpacingTokens } from '@grafana/data';
import { Button, DataLinkButton, Icon, Stack } from '..';
import { useStyles2 } from '../../themes';
import { Trans } from '../../utils/i18n';
import { ActionButton } from '../Actions/ActionButton';
+import { ResponsiveProp } from '../Layout/utils/responsiveness';
interface VizTooltipFooterProps {
dataLinks: Array>;
@@ -15,50 +17,75 @@ interface VizTooltipFooterProps {
export const ADD_ANNOTATION_ID = 'add-annotation-button';
-const renderDataLinks = (dataLinks: LinkModel[], styles: ReturnType) => {
- const oneClickLink = dataLinks.find((link) => link.oneClick === true);
+type RenderOneClickTrans = (title: string) => React.ReactNode;
+type RenderItem = (
+ item: T,
+ idx: number,
+ styles: ReturnType
+) => React.ReactNode;
+
+function makeRenderLinksOrActions(
+ renderOneClickTrans: RenderOneClickTrans,
+ renderItem: RenderItem,
+ itemGap?: ResponsiveProp
+) {
+ const renderLinksOrActions = (items: T[], styles: ReturnType) => {
+ if (items.length === 0) {
+ return;
+ }
+
+ const oneClickItem = items.find((item) => item.oneClick === true);
+
+ if (oneClickItem != null) {
+ return (
+
+
+
+
+ {renderOneClickTrans(oneClickItem.title)}
+
+
+
+ );
+ }
- if (oneClickLink != null) {
return (
-
-
-
-
- Click to open {{ linkTitle: oneClickLink.title }}
-
-
-
+
+
+ {items.map((item, i) => renderItem(item, i, styles))}
+
+
);
- }
+ };
- return (
-
- {dataLinks.map((link, i) => (
-
- ))}
-
- );
-};
+ return renderLinksOrActions;
+}
-const renderActions = (actions: ActionModel[]) => {
- return (
-
- {actions.map((action, i) => (
-
- ))}
-
- );
-};
+const renderDataLinks = makeRenderLinksOrActions(
+ (title) => (
+ Click to open {{ linkTitle: title }}
+ ),
+ (item, i, styles) => (
+
+ ),
+ 0.5
+);
+
+const renderActions = makeRenderLinksOrActions(
+ (title) => Click to {{ actionTitle: title }} ,
+ (item, i, styles) =>
+);
export const VizTooltipFooter = ({ dataLinks, actions = [], annotate }: VizTooltipFooterProps) => {
const styles = useStyles2(getStyles);
- const hasOneClickLink = dataLinks.some((link) => link.oneClick === true);
+ const hasOneClickLink = useMemo(() => dataLinks.some((link) => link.oneClick === true), [dataLinks]);
+ const hasOneClickAction = useMemo(() => actions.some((action) => action.oneClick === true), [actions]);
return (
- {dataLinks.length > 0 &&
{renderDataLinks(dataLinks, styles)}
}
- {!hasOneClickLink && actions.length > 0 &&
{renderActions(actions)}
}
- {!hasOneClickLink && annotate != null && (
+ {!hasOneClickAction && renderDataLinks(dataLinks, styles)}
+ {!hasOneClickLink && renderActions(actions, styles)}
+ {!hasOneClickLink && !hasOneClickAction && annotate != null && (
Add annotation
diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go
index 1f6045d4851..a464727a31b 100644
--- a/pkg/api/folder_test.go
+++ b/pkg/api/folder_test.go
@@ -15,7 +15,6 @@ import (
clientrest "k8s.io/client-go/rest"
"github.com/grafana/grafana/pkg/api/dtos"
- "github.com/grafana/grafana/pkg/apimachinery/identity"
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -528,10 +527,6 @@ func (m mockClientConfigProvider) GetDirectRestConfig(c *contextmodel.ReqContext
}
}
-func (m mockClientConfigProvider) GetRestConfigForBackgroundWorker(requester func() identity.Requester) *clientrest.Config {
- return nil
-}
-
func (m mockClientConfigProvider) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) {}
// for now, test only the general folder
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index e4cb80c1495..92cff840635 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -207,7 +207,6 @@ type HTTPServer struct {
tempUserService tempUser.Service
loginAttemptService loginAttempt.Service
orgService org.Service
- idService auth.IDService
orgDeletionService org.DeletionService
TeamService team.Service
accesscontrolService accesscontrol.Service
@@ -273,7 +272,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, oauthTokenService oauthtoken.OAuthTokenService,
statsService stats.Service, authnService authn.Service, pluginsCDNService *pluginscdn.Service, promGatherer prometheus.Gatherer,
starApi *starApi.API, promRegister prometheus.Registerer, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, anonService anonymous.Service,
- userVerifier user.Verifier, pluginPreinstall plugininstaller.Preinstall, idService auth.IDService,
+ userVerifier user.Verifier, pluginPreinstall plugininstaller.Preinstall,
) (*HTTPServer, error) {
web.Env = cfg.Env
m := web.New()
@@ -361,7 +360,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
tempUserService: tempUserService,
loginAttemptService: loginAttemptService,
orgService: orgService,
- idService: idService,
orgDeletionService: orgDeletionService,
TeamService: teamService,
navTreeService: navTreeService,
diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go
index f19d4138bb8..1b83b4e3b77 100644
--- a/pkg/api/org_users.go
+++ b/pkg/api/org_users.go
@@ -7,11 +7,9 @@ import (
"net/http"
"strconv"
- claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/services/accesscontrol"
- "github.com/grafana/grafana/pkg/services/authn"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
@@ -434,10 +432,6 @@ func (hs *HTTPServer) updateOrgUserHelper(c *contextmodel.ReqContext, cmd org.Up
}
}
- if err := hs.idService.RemoveIDToken(c.Req.Context(), &authn.Identity{ID: strconv.FormatInt(cmd.UserID, 10), Type: claims.TypeUser, OrgID: cmd.OrgID}); err != nil {
- return response.Error(http.StatusInternalServerError, "Failed to invalidate the ID token cache", err)
- }
-
if err := hs.orgService.UpdateOrgUser(c.Req.Context(), &cmd); err != nil {
if errors.Is(err, org.ErrLastOrgAdmin) {
return response.Error(http.StatusBadRequest, "Cannot change role so that there is no organization admin left", nil)
diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go
index 324e9da2b39..59bb47330d6 100644
--- a/pkg/api/org_users_test.go
+++ b/pkg/api/org_users_test.go
@@ -9,12 +9,9 @@ import (
"strings"
"testing"
- "github.com/grafana/authlib/types"
- "github.com/grafana/grafana/pkg/services/auth/idtest"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/api/dtos"
@@ -205,12 +202,11 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) {
func TestOrgUsersAPIEndpoint_updateOrgRole(t *testing.T) {
type testCase struct {
- desc string
- SkipOrgRoleSync bool
- AuthEnabled bool
- AuthModule string
- shouldInvalidateIDToken bool
- expectedCode int
+ desc string
+ SkipOrgRoleSync bool
+ AuthEnabled bool
+ AuthModule string
+ expectedCode int
}
permissions := []accesscontrol.Permission{
{Action: accesscontrol.ActionOrgUsersRead, Scope: "users:*"},
@@ -220,12 +216,11 @@ func TestOrgUsersAPIEndpoint_updateOrgRole(t *testing.T) {
}
tests := []testCase{
{
- desc: "should be able to change basicRole when skip_org_role_sync true",
- SkipOrgRoleSync: true,
- AuthEnabled: true,
- AuthModule: login.LDAPAuthModule,
- shouldInvalidateIDToken: true,
- expectedCode: http.StatusOK,
+ desc: "should be able to change basicRole when skip_org_role_sync true",
+ SkipOrgRoleSync: true,
+ AuthEnabled: true,
+ AuthModule: login.LDAPAuthModule,
+ expectedCode: http.StatusOK,
},
{
desc: "should not be able to change basicRole when skip_org_role_sync false",
@@ -242,20 +237,18 @@ func TestOrgUsersAPIEndpoint_updateOrgRole(t *testing.T) {
expectedCode: http.StatusForbidden,
},
{
- desc: "should be able to change basicRole with a basic Auth",
- SkipOrgRoleSync: false,
- AuthEnabled: false,
- AuthModule: "",
- shouldInvalidateIDToken: true,
- expectedCode: http.StatusOK,
+ desc: "should be able to change basicRole with a basic Auth",
+ SkipOrgRoleSync: false,
+ AuthEnabled: false,
+ AuthModule: "",
+ expectedCode: http.StatusOK,
},
{
- desc: "should be able to change basicRole with a basic Auth",
- SkipOrgRoleSync: true,
- AuthEnabled: true,
- AuthModule: "",
- shouldInvalidateIDToken: true,
- expectedCode: http.StatusOK,
+ desc: "should be able to change basicRole with a basic Auth",
+ SkipOrgRoleSync: true,
+ AuthEnabled: true,
+ AuthModule: "",
+ expectedCode: http.StatusOK,
},
}
@@ -286,11 +279,6 @@ func TestOrgUsersAPIEndpoint_updateOrgRole(t *testing.T) {
}
hs.userService = &usertest.FakeUserService{ExpectedSignedInUser: userWithPermissions}
hs.orgService = &orgtest.FakeOrgService{}
- idService := &idtest.MockService{}
- if tt.shouldInvalidateIDToken {
- idService.On("RemoveIDToken", mock.Anything, mock.Anything).Return(nil)
- }
- hs.idService = idService
hs.SocialService = &socialtest.FakeSocialService{
ExpectedAuthInfoProvider: &social.OAuthInfo{Enabled: tt.AuthEnabled, SkipOrgRoleSync: tt.SkipOrgRoleSync},
}
@@ -627,7 +615,6 @@ func TestOrgUsersAPIEndpointWithSetPerms_AccessControl(t *testing.T) {
ExpectedUser: &user.User{},
ExpectedSignedInUser: userWithPermissions(1, tt.permissions),
}
- hs.idService = &idtest.FakeService{}
hs.accesscontrolService = &actest.FakeService{}
})
@@ -650,24 +637,16 @@ func TestPatchOrgUsersAPIEndpoint_AccessControl(t *testing.T) {
name string
role org.RoleType
permissions []accesscontrol.Permission
- setup func(*testing.T, *idtest.MockService)
input string
expectedCode int
}
tests := []testCase{
{
- name: "user with permissions can update org role",
- permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersWrite, Scope: "users:*"}},
- role: org.RoleAdmin,
- input: `{"role": "Viewer"}`,
- setup: func(t *testing.T, idService *idtest.MockService) {
- idService.On("RemoveIDToken", mock.Anything, mock.MatchedBy(func(id *authn.Identity) bool {
- return id.GetIdentityType() == types.TypeUser &&
- id.GetID() == "user:1" &&
- id.GetOrgID() == int64(1)
- })).Return(nil)
- },
+ name: "user with permissions can update org role",
+ permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersWrite, Scope: "users:*"}},
+ role: org.RoleAdmin,
+ input: `{"role": "Viewer"}`,
expectedCode: http.StatusOK,
},
{
@@ -694,11 +673,6 @@ func TestPatchOrgUsersAPIEndpoint_AccessControl(t *testing.T) {
AuthModule: "",
},
}
- idService := &idtest.MockService{}
- if tt.setup != nil {
- tt.setup(t, idService)
- }
- hs.idService = idService
hs.accesscontrolService = &actest.FakeService{}
hs.userService = &usertest.FakeUserService{
ExpectedUser: &user.User{},
diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go
index 598abbeaae2..7600f000e7e 100644
--- a/pkg/apimachinery/identity/context.go
+++ b/pkg/apimachinery/identity/context.go
@@ -100,6 +100,7 @@ func getWildcardPermissions(actions ...string) map[string][]string {
// serviceIdentityPermissions is a list of wildcard permissions for provided actions.
// We should add every action required "internally" here.
var serviceIdentityPermissions = getWildcardPermissions(
+ "annotations:read",
"folders:read",
"folders:write",
"folders:create",
@@ -107,10 +108,14 @@ var serviceIdentityPermissions = getWildcardPermissions(
"dashboards:read",
"dashboards:write",
"dashboards:create",
+ "datasources:query",
"datasources:read",
"datasources:delete",
"alert.provisioning:write",
"alert.provisioning.secrets:read",
+ "users:read", // accesscontrol.ActionUsersRead,
+ "org.users:read", // accesscontrol.ActionOrgUsersRead,
+ "teams:read", // accesscontrol.ActionTeamsRead,
)
func IsServiceIdentity(ctx context.Context) bool {
diff --git a/pkg/apis/dashboard/migration/migrate.go b/pkg/apis/dashboard/migration/migrate.go
index 36a4679f191..2a419596767 100644
--- a/pkg/apis/dashboard/migration/migrate.go
+++ b/pkg/apis/dashboard/migration/migrate.go
@@ -9,10 +9,6 @@ func Migrate(dash map[string]interface{}, targetVersion int) error {
inputVersion := schemaversion.GetSchemaVersion(dash)
dash["schemaVersion"] = inputVersion
- if inputVersion < schemaversion.MINIUM_VERSION {
- return schemaversion.NewMinimumVersionError(inputVersion)
- }
-
for nextVersion := inputVersion + 1; nextVersion <= targetVersion; nextVersion++ {
if migration, ok := schemaversion.Migrations[nextVersion]; ok {
if err := migration(dash); err != nil {
diff --git a/pkg/apis/dashboard/migration/migrate_test.go b/pkg/apis/dashboard/migration/migrate_test.go
index a24baa993ee..e037c37f00f 100644
--- a/pkg/apis/dashboard/migration/migrate_test.go
+++ b/pkg/apis/dashboard/migration/migrate_test.go
@@ -22,15 +22,6 @@ func TestMigrate(t *testing.T) {
files, err := os.ReadDir(INPUT_DIR)
require.NoError(t, err)
- t.Run("minimum version check", func(t *testing.T) {
- err := migration.Migrate(map[string]interface{}{
- "schemaVersion": schemaversion.MINIUM_VERSION - 1,
- }, schemaversion.MINIUM_VERSION)
-
- var minVersionErr = schemaversion.NewMinimumVersionError(schemaversion.MINIUM_VERSION - 1)
- require.ErrorAs(t, err, &minVersionErr)
- })
-
for _, f := range files {
if f.IsDir() {
continue
diff --git a/pkg/apis/dashboard/migration/schemaversion/errors.go b/pkg/apis/dashboard/migration/schemaversion/errors.go
index 110a596a1ad..f5bbbe7d1fa 100644
--- a/pkg/apis/dashboard/migration/schemaversion/errors.go
+++ b/pkg/apis/dashboard/migration/schemaversion/errors.go
@@ -2,23 +2,8 @@ package schemaversion
import "fmt"
-var _ error = &MinimumVersionError{}
var _ error = &MigrationError{}
-// MinimumVersionError is an error that is returned when the schema version is below the minimum version.
-func NewMinimumVersionError(inputVersion int) *MinimumVersionError {
- return &MinimumVersionError{inputVersion: inputVersion}
-}
-
-// MinimumVersionError is an error type for minimum version errors.
-type MinimumVersionError struct {
- inputVersion int
-}
-
-func (e *MinimumVersionError) Error() string {
- return fmt.Errorf("input schema version is below minimum version. input: %d minimum: %d", e.inputVersion, MINIUM_VERSION).Error()
-}
-
// ErrMigrationFailed is an error that is returned when a migration fails.
func NewMigrationError(msg string, currentVersion, targetVersion int) *MigrationError {
return &MigrationError{
diff --git a/pkg/apis/dashboard/migration/schemaversion/migrations.go b/pkg/apis/dashboard/migration/schemaversion/migrations.go
index f689894cf34..3d83da73a3f 100644
--- a/pkg/apis/dashboard/migration/schemaversion/migrations.go
+++ b/pkg/apis/dashboard/migration/schemaversion/migrations.go
@@ -4,14 +4,14 @@ import "strconv"
type SchemaVersionMigrationFunc func(map[string]interface{}) error
-const (
- MINIUM_VERSION = 38
- LATEST_VERSION = 40
-)
+const LATEST_VERSION = 41
var Migrations = map[int]SchemaVersionMigrationFunc{
+ 37: V37,
+ 38: V38,
39: V39,
40: V40,
+ 41: V41,
}
func GetSchemaVersion(dash map[string]interface{}) int {
diff --git a/pkg/apis/dashboard/migration/schemaversion/v37.go b/pkg/apis/dashboard/migration/schemaversion/v37.go
new file mode 100644
index 00000000000..c040daf842c
--- /dev/null
+++ b/pkg/apis/dashboard/migration/schemaversion/v37.go
@@ -0,0 +1,62 @@
+package schemaversion
+
+// V37 normalizes legend configuration in panels to use a consistent format:
+// - Converts boolean legend values to object format
+// - Standardizes hidden legends to use showLegend: false with displayMode: list
+// - Ensures visible legends have showLegend: true
+func V37(dashboard map[string]interface{}) error {
+ dashboard["schemaVersion"] = int(37)
+
+ panels, ok := dashboard["panels"].([]interface{})
+ if !ok {
+ return nil
+ }
+
+ for _, panel := range panels {
+ p, ok := panel.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ options, ok := p["options"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ // Skip if no legend config exists
+ legendValue := options["legend"]
+ if legendValue == nil {
+ continue
+ }
+
+ // Convert boolean legend to object format
+ if legendBool, ok := legendValue.(bool); ok {
+ options["legend"] = map[string]interface{}{
+ "displayMode": "list",
+ "showLegend": legendBool,
+ }
+ continue
+ }
+
+ // Handle object format legend
+ legend, ok := legendValue.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ displayMode, hasDisplayMode := legend["displayMode"].(string)
+ showLegend, hasShowLegend := legend["showLegend"].(bool)
+
+ // Normalize hidden legends
+ if (hasDisplayMode && displayMode == "hidden") || (hasShowLegend && !showLegend) {
+ legend["displayMode"] = "list"
+ legend["showLegend"] = false
+ continue
+ }
+
+ // Ensure visible legends have showLegend true
+ legend["showLegend"] = true
+ }
+
+ return nil
+}
diff --git a/pkg/apis/dashboard/migration/schemaversion/v37_test.go b/pkg/apis/dashboard/migration/schemaversion/v37_test.go
new file mode 100644
index 00000000000..e0b2f4c27cb
--- /dev/null
+++ b/pkg/apis/dashboard/migration/schemaversion/v37_test.go
@@ -0,0 +1,170 @@
+package schemaversion_test
+
+import (
+ "testing"
+
+ "github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
+)
+
+func TestV37(t *testing.T) {
+ tests := []migrationTestCase{
+ {
+ name: "no legend config",
+ input: map[string]interface{}{
+ "schemaVersion": 36,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "graph",
+ "options": map[string]interface{}{},
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "schemaVersion": 37,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "graph",
+ "options": map[string]interface{}{},
+ },
+ },
+ },
+ },
+ {
+ name: "boolean legend true",
+ input: map[string]interface{}{
+ "schemaVersion": 36,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "options": map[string]interface{}{
+ "legend": true,
+ },
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "schemaVersion": 37,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "options": map[string]interface{}{
+ "legend": map[string]interface{}{
+ "displayMode": "list",
+ "showLegend": true,
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "boolean legend false",
+ input: map[string]interface{}{
+ "schemaVersion": 36,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "options": map[string]interface{}{
+ "legend": false,
+ },
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "schemaVersion": 37,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "options": map[string]interface{}{
+ "legend": map[string]interface{}{
+ "displayMode": "list",
+ "showLegend": false,
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "hidden displayMode",
+ input: map[string]interface{}{
+ "schemaVersion": 36,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "options": map[string]interface{}{
+ "legend": map[string]interface{}{
+ "displayMode": "hidden",
+ },
+ },
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "schemaVersion": 37,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "options": map[string]interface{}{
+ "legend": map[string]interface{}{
+ "displayMode": "list",
+ "showLegend": false,
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "showLegend false",
+ input: map[string]interface{}{
+ "schemaVersion": 36,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "options": map[string]interface{}{
+ "legend": map[string]interface{}{
+ "showLegend": false,
+ },
+ },
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "schemaVersion": 37,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "options": map[string]interface{}{
+ "legend": map[string]interface{}{
+ "displayMode": "list",
+ "showLegend": false,
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "visible legend",
+ input: map[string]interface{}{
+ "schemaVersion": 36,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "options": map[string]interface{}{
+ "legend": map[string]interface{}{
+ "displayMode": "table",
+ },
+ },
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "schemaVersion": 37,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "options": map[string]interface{}{
+ "legend": map[string]interface{}{
+ "displayMode": "table",
+ "showLegend": true,
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+ runMigrationTests(t, tests, schemaversion.V37)
+}
diff --git a/pkg/apis/dashboard/migration/schemaversion/v38.go b/pkg/apis/dashboard/migration/schemaversion/v38.go
new file mode 100644
index 00000000000..a750ac4223f
--- /dev/null
+++ b/pkg/apis/dashboard/migration/schemaversion/v38.go
@@ -0,0 +1,122 @@
+package schemaversion
+
+// V38 updates the configuration of the table panel to use the new cellOptions format
+// and updates the overrides to use the new cellOptions format
+func V38(dashboard map[string]interface{}) error {
+ dashboard["schemaVersion"] = int(38)
+
+ panels, ok := dashboard["panels"].([]interface{})
+ if !ok {
+ return nil
+ }
+
+ for _, panel := range panels {
+ p, ok := panel.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ // Only process table panels
+ if p["type"] != "table" {
+ continue
+ }
+
+ fieldConfig, ok := p["fieldConfig"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ defaults, ok := fieldConfig["defaults"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ custom, ok := defaults["custom"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ // Migrate displayMode to cellOptions
+ if displayMode, exists := custom["displayMode"]; exists {
+ if displayModeStr, ok := displayMode.(string); ok {
+ custom["cellOptions"] = migrateTableDisplayModeToCellOptions(displayModeStr)
+ }
+ // Delete the legacy field
+ delete(custom, "displayMode")
+ }
+
+ // Update any overrides referencing the cell display mode
+ migrateOverrides(fieldConfig)
+ }
+
+ return nil
+}
+
+// migrateOverrides updates the overrides configuration to use the new cellOptions format
+func migrateOverrides(fieldConfig map[string]interface{}) {
+ overrides, ok := fieldConfig["overrides"].([]interface{})
+ if !ok {
+ return
+ }
+
+ for _, override := range overrides {
+ o, ok := override.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ properties, ok := o["properties"].([]interface{})
+ if !ok {
+ continue
+ }
+
+ for _, property := range properties {
+ prop, ok := property.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ // Update the id to cellOptions
+ if prop["id"] == "custom.displayMode" {
+ prop["id"] = "custom.cellOptions"
+ if value, ok := prop["value"]; ok {
+ if valueStr, ok := value.(string); ok {
+ prop["value"] = migrateTableDisplayModeToCellOptions(valueStr)
+ }
+ }
+ }
+ }
+ }
+}
+
+// migrateTableDisplayModeToCellOptions converts the old displayMode string to the new cellOptions format
+func migrateTableDisplayModeToCellOptions(displayMode string) map[string]interface{} {
+ switch displayMode {
+ case "basic", "gradient-gauge", "lcd-gauge":
+ gaugeMode := "basic"
+ if displayMode == "gradient-gauge" {
+ gaugeMode = "gradient"
+ } else if displayMode == "lcd-gauge" {
+ gaugeMode = "lcd"
+ }
+ return map[string]interface{}{
+ "type": "gauge",
+ "mode": gaugeMode,
+ }
+
+ case "color-background", "color-background-solid":
+ mode := "basic"
+ if displayMode == "color-background" {
+ mode = "gradient"
+ }
+ return map[string]interface{}{
+ "type": "color-background",
+ "mode": mode,
+ }
+
+ default:
+ return map[string]interface{}{
+ "type": displayMode,
+ }
+ }
+}
diff --git a/pkg/apis/dashboard/migration/schemaversion/v38_test.go b/pkg/apis/dashboard/migration/schemaversion/v38_test.go
new file mode 100644
index 00000000000..f4bbb8c9cb6
--- /dev/null
+++ b/pkg/apis/dashboard/migration/schemaversion/v38_test.go
@@ -0,0 +1,251 @@
+package schemaversion_test
+
+import (
+ "testing"
+
+ "github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
+)
+
+func TestV38(t *testing.T) {
+ tests := []migrationTestCase{
+ {
+ name: "no table panels",
+ input: map[string]interface{}{
+ "schemaVersion": 37,
+ "title": "Test Dashboard",
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "graph",
+ "title": "Panel 1",
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "title": "Test Dashboard",
+ "schemaVersion": 38,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "graph",
+ "title": "Panel 1",
+ },
+ },
+ },
+ },
+ {
+ name: "table panel with basic gauge displayMode",
+ input: map[string]interface{}{
+ "schemaVersion": 37,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "table",
+ "fieldConfig": map[string]interface{}{
+ "defaults": map[string]interface{}{
+ "custom": map[string]interface{}{
+ "displayMode": "basic",
+ },
+ },
+ },
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "schemaVersion": 38,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "table",
+ "fieldConfig": map[string]interface{}{
+ "defaults": map[string]interface{}{
+ "custom": map[string]interface{}{
+ "cellOptions": map[string]interface{}{
+ "type": "gauge",
+ "mode": "basic",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "table panel with gradient-gauge displayMode",
+ input: map[string]interface{}{
+ "schemaVersion": 37,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "table",
+ "fieldConfig": map[string]interface{}{
+ "defaults": map[string]interface{}{
+ "custom": map[string]interface{}{
+ "displayMode": "gradient-gauge",
+ },
+ },
+ },
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "schemaVersion": 38,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "table",
+ "fieldConfig": map[string]interface{}{
+ "defaults": map[string]interface{}{
+ "custom": map[string]interface{}{
+ "cellOptions": map[string]interface{}{
+ "type": "gauge",
+ "mode": "gradient",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "table panel with lcd-gauge displayMode",
+ input: map[string]interface{}{
+ "schemaVersion": 37,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "table",
+ "fieldConfig": map[string]interface{}{
+ "defaults": map[string]interface{}{
+ "custom": map[string]interface{}{
+ "displayMode": "lcd-gauge",
+ },
+ },
+ },
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "schemaVersion": 38,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "table",
+ "fieldConfig": map[string]interface{}{
+ "defaults": map[string]interface{}{
+ "custom": map[string]interface{}{
+ "cellOptions": map[string]interface{}{
+ "type": "gauge",
+ "mode": "lcd",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "table panel with color-background displayMode",
+ input: map[string]interface{}{
+ "schemaVersion": 37,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "table",
+ "fieldConfig": map[string]interface{}{
+ "defaults": map[string]interface{}{
+ "custom": map[string]interface{}{
+ "displayMode": "color-background",
+ },
+ },
+ },
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "schemaVersion": 38,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "table",
+ "fieldConfig": map[string]interface{}{
+ "defaults": map[string]interface{}{
+ "custom": map[string]interface{}{
+ "cellOptions": map[string]interface{}{
+ "type": "color-background",
+ "mode": "gradient",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "table panel with color-background-solid displayMode",
+ input: map[string]interface{}{
+ "schemaVersion": 37,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "table",
+ "fieldConfig": map[string]interface{}{
+ "defaults": map[string]interface{}{
+ "custom": map[string]interface{}{
+ "displayMode": "color-background-solid",
+ },
+ },
+ },
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "schemaVersion": 38,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "table",
+ "fieldConfig": map[string]interface{}{
+ "defaults": map[string]interface{}{
+ "custom": map[string]interface{}{
+ "cellOptions": map[string]interface{}{
+ "type": "color-background",
+ "mode": "basic",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "table panel with default displayMode",
+ input: map[string]interface{}{
+ "schemaVersion": 37,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "table",
+ "fieldConfig": map[string]interface{}{
+ "defaults": map[string]interface{}{
+ "custom": map[string]interface{}{
+ "displayMode": "some-other-mode",
+ },
+ },
+ },
+ },
+ },
+ },
+ expected: map[string]interface{}{
+ "schemaVersion": 38,
+ "panels": []interface{}{
+ map[string]interface{}{
+ "type": "table",
+ "fieldConfig": map[string]interface{}{
+ "defaults": map[string]interface{}{
+ "custom": map[string]interface{}{
+ "cellOptions": map[string]interface{}{
+ "type": "some-other-mode",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+ runMigrationTests(t, tests, schemaversion.V38)
+}
diff --git a/pkg/apis/dashboard/migration/schemaversion/v41.go b/pkg/apis/dashboard/migration/schemaversion/v41.go
new file mode 100644
index 00000000000..1faafea8285
--- /dev/null
+++ b/pkg/apis/dashboard/migration/schemaversion/v41.go
@@ -0,0 +1,11 @@
+package schemaversion
+
+func V41(dash map[string]interface{}) error {
+ dash["schemaVersion"] = int(41)
+ if timepicker, ok := dash["timepicker"].(map[string]interface{}); ok {
+ // time_options is a legacy property that was not used since grafana version 5
+ // therefore deprecating this property from the schema
+ delete(timepicker, "time_options")
+ }
+ return nil
+}
diff --git a/pkg/apis/dashboard/migration/schemaversion/v41_test.go b/pkg/apis/dashboard/migration/schemaversion/v41_test.go
new file mode 100644
index 00000000000..d0f11d227f3
--- /dev/null
+++ b/pkg/apis/dashboard/migration/schemaversion/v41_test.go
@@ -0,0 +1,38 @@
+package schemaversion_test
+
+import (
+ "testing"
+
+ "github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
+)
+
+func TestV41(t *testing.T) {
+ tests := []migrationTestCase{
+ {
+ name: "time_options is removed",
+ input: map[string]interface{}{
+ "title": "Test Dashboard",
+ "timepicker": map[string]interface{}{
+ "time_options": []string{"1m", "5m", "15m", "1h", "6h", "12h", "24h"},
+ },
+ },
+ expected: map[string]interface{}{
+ "title": "Test Dashboard",
+ "schemaVersion": 41,
+ "timepicker": map[string]interface{}{},
+ },
+ },
+ {
+ name: "timepicker is not set",
+ input: map[string]interface{}{
+ "title": "Test Dashboard",
+ },
+ expected: map[string]interface{}{
+ "title": "Test Dashboard",
+ "schemaVersion": 41,
+ },
+ },
+ }
+
+ runMigrationTests(t, tests, schemaversion.V41)
+}
diff --git a/pkg/apis/dashboard/migration/testdata/input/36.legend_normalization.json b/pkg/apis/dashboard/migration/testdata/input/36.legend_normalization.json
new file mode 100644
index 00000000000..7776ccdf0cb
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/input/36.legend_normalization.json
@@ -0,0 +1,125 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations \u0026 Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "type": "graph",
+ "options": {},
+ "title": "No Legend Config",
+ "id": 1,
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ }
+ },
+ {
+ "options": {
+ "legend": true
+ },
+ "title": "Boolean Legend True",
+ "id": 2,
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ }
+ },
+ {
+ "options": {
+ "legend": false
+ },
+ "title": "Boolean Legend False",
+ "id": 3,
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ }
+ },
+ {
+ "options": {
+ "legend": {
+ "displayMode": "hidden"
+ }
+ },
+ "title": "Hidden DisplayMode",
+ "id": 4,
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ }
+ },
+ {
+ "options": {
+ "legend": {
+ "showLegend": false
+ }
+ },
+ "title": "ShowLegend False",
+ "id": 5,
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 16
+ }
+ },
+ {
+ "options": {
+ "legend": {
+ "displayMode": "table"
+ }
+ },
+ "title": "Visible Legend",
+ "id": 6,
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 16
+ }
+ }
+ ],
+ "preload": false,
+ "refresh": true,
+ "schemaVersion": 36,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {
+ "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ },
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/pkg/apis/dashboard/migration/testdata/input/37.timeseries_table_display_mode.json b/pkg/apis/dashboard/migration/testdata/input/37.timeseries_table_display_mode.json
new file mode 100644
index 00000000000..20fa6fc0371
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/input/37.timeseries_table_display_mode.json
@@ -0,0 +1,362 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations \u0026 Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "displayMode": "basic"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Basic Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "displayMode": "gradient-gauge"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Gradient Gauge Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "displayMode": "lcd-gauge"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ },
+ "id": 3,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "LCD Gauge Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "displayMode": "color-background"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "id": 4,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Color Background Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "displayMode": "color-background-solid"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 16
+ },
+ "id": 5,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Color Background Solid Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "displayMode": "some-other-mode"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 16
+ },
+ "id": 6,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Other Display Mode",
+ "type": "table"
+ }
+
+
+ ],
+ "preload": false,
+ "refresh": true,
+ "schemaVersion": 37,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {
+ "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ },
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/pkg/apis/dashboard/migration/testdata/input/38.transform_timeseries_table.json b/pkg/apis/dashboard/migration/testdata/input/38.transform_timeseries_table.json
index e5f08f17c69..9afbe33c607 100644
--- a/pkg/apis/dashboard/migration/testdata/input/38.transform_timeseries_table.json
+++ b/pkg/apis/dashboard/migration/testdata/input/38.transform_timeseries_table.json
@@ -145,7 +145,9 @@
"from": "now-6h",
"to": "now"
},
- "timepicker": {},
+ "timepicker": {
+ "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ },
"timezone": "utc",
"title": "New dashboard",
"version": 0,
diff --git a/pkg/apis/dashboard/migration/testdata/input/39.refresh_true.json b/pkg/apis/dashboard/migration/testdata/input/39.refresh_true.json
index 4ea2be531f9..844bd81eb23 100644
--- a/pkg/apis/dashboard/migration/testdata/input/39.refresh_true.json
+++ b/pkg/apis/dashboard/migration/testdata/input/39.refresh_true.json
@@ -124,7 +124,9 @@
"from": "now-6h",
"to": "now"
},
- "timepicker": {},
+ "timepicker": {
+ "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ },
"timezone": "utc",
"title": "New dashboard",
"version": 0,
diff --git a/pkg/apis/dashboard/migration/testdata/input/40.time_picker_time_options.json b/pkg/apis/dashboard/migration/testdata/input/40.time_picker_time_options.json
new file mode 100644
index 00000000000..5b6071de054
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/input/40.time_picker_time_options.json
@@ -0,0 +1,136 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisBorderShow": false,
+ "axisCenteredZero": false,
+ "axisColorMode": "text",
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "barWidthFactor": 0.6,
+ "drawStyle": "line",
+ "fillOpacity": 0,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "insertNulls": false,
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "hideZeros": false,
+ "mode": "single",
+ "sort": "none"
+ }
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "queryType": "randomWalk",
+ "refId": "A"
+ }
+ ],
+ "title": "Panel Title",
+ "type": "timeseries"
+ }
+ ],
+ "preload": false,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {
+ "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ },
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": "",
+ "refresh": "",
+ "schemaVersion": 40
+ }
\ No newline at end of file
diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.37.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.37.json
new file mode 100644
index 00000000000..27e61667230
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.37.json
@@ -0,0 +1,144 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations \u0026 Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {},
+ "title": "No Legend Config",
+ "type": "graph"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": true
+ }
+ },
+ "title": "Boolean Legend True"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ },
+ "id": 3,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "Boolean Legend False"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "id": 4,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "Hidden DisplayMode"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 16
+ },
+ "id": 5,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "ShowLegend False"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 16
+ },
+ "id": 6,
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "showLegend": true
+ }
+ },
+ "title": "Visible Legend"
+ }
+ ],
+ "preload": false,
+ "refresh": true,
+ "schemaVersion": 37,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.38.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.38.json
new file mode 100644
index 00000000000..de4220f3b61
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.38.json
@@ -0,0 +1,144 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations \u0026 Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {},
+ "title": "No Legend Config",
+ "type": "graph"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": true
+ }
+ },
+ "title": "Boolean Legend True"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ },
+ "id": 3,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "Boolean Legend False"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "id": 4,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "Hidden DisplayMode"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 16
+ },
+ "id": 5,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "ShowLegend False"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 16
+ },
+ "id": 6,
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "showLegend": true
+ }
+ },
+ "title": "Visible Legend"
+ }
+ ],
+ "preload": false,
+ "refresh": true,
+ "schemaVersion": 38,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.39.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.39.json
new file mode 100644
index 00000000000..472dbdd70a7
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.39.json
@@ -0,0 +1,144 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations \u0026 Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {},
+ "title": "No Legend Config",
+ "type": "graph"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": true
+ }
+ },
+ "title": "Boolean Legend True"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ },
+ "id": 3,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "Boolean Legend False"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "id": 4,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "Hidden DisplayMode"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 16
+ },
+ "id": 5,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "ShowLegend False"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 16
+ },
+ "id": 6,
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "showLegend": true
+ }
+ },
+ "title": "Visible Legend"
+ }
+ ],
+ "preload": false,
+ "refresh": true,
+ "schemaVersion": 39,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.40.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.40.json
new file mode 100644
index 00000000000..e097b1e8402
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.40.json
@@ -0,0 +1,144 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations \u0026 Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {},
+ "title": "No Legend Config",
+ "type": "graph"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": true
+ }
+ },
+ "title": "Boolean Legend True"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ },
+ "id": 3,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "Boolean Legend False"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "id": 4,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "Hidden DisplayMode"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 16
+ },
+ "id": 5,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "ShowLegend False"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 16
+ },
+ "id": 6,
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "showLegend": true
+ }
+ },
+ "title": "Visible Legend"
+ }
+ ],
+ "preload": false,
+ "refresh": "",
+ "schemaVersion": 40,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.41.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.41.json
new file mode 100644
index 00000000000..fda6883a462
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.41.json
@@ -0,0 +1,132 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations \u0026 Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {},
+ "title": "No Legend Config",
+ "type": "graph"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": true
+ }
+ },
+ "title": "Boolean Legend True"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ },
+ "id": 3,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "Boolean Legend False"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "id": 4,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "Hidden DisplayMode"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 16
+ },
+ "id": 5,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "showLegend": false
+ }
+ },
+ "title": "ShowLegend False"
+ },
+ {
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 16
+ },
+ "id": 6,
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "showLegend": true
+ }
+ },
+ "title": "Visible Legend"
+ }
+ ],
+ "preload": false,
+ "refresh": "",
+ "schemaVersion": 41,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.38.json b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.38.json
new file mode 100644
index 00000000000..5a17d602e82
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.38.json
@@ -0,0 +1,387 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations \u0026 Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "basic",
+ "type": "gauge"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Basic Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "gradient",
+ "type": "gauge"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Gradient Gauge Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "lcd",
+ "type": "gauge"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ },
+ "id": 3,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "LCD Gauge Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "gradient",
+ "type": "color-background"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "id": 4,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Color Background Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "basic",
+ "type": "color-background"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 16
+ },
+ "id": 5,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Color Background Solid Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "type": "some-other-mode"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 16
+ },
+ "id": 6,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Other Display Mode",
+ "type": "table"
+ }
+ ],
+ "preload": false,
+ "refresh": true,
+ "schemaVersion": 38,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.39.json b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.39.json
new file mode 100644
index 00000000000..dee3d62af57
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.39.json
@@ -0,0 +1,387 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations \u0026 Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "basic",
+ "type": "gauge"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Basic Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "gradient",
+ "type": "gauge"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Gradient Gauge Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "lcd",
+ "type": "gauge"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ },
+ "id": 3,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "LCD Gauge Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "gradient",
+ "type": "color-background"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "id": 4,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Color Background Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "basic",
+ "type": "color-background"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 16
+ },
+ "id": 5,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Color Background Solid Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "type": "some-other-mode"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 16
+ },
+ "id": 6,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Other Display Mode",
+ "type": "table"
+ }
+ ],
+ "preload": false,
+ "refresh": true,
+ "schemaVersion": 39,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.40.json b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.40.json
new file mode 100644
index 00000000000..d5ddc2a55a8
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.40.json
@@ -0,0 +1,387 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations \u0026 Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "basic",
+ "type": "gauge"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Basic Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "gradient",
+ "type": "gauge"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Gradient Gauge Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "lcd",
+ "type": "gauge"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ },
+ "id": 3,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "LCD Gauge Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "gradient",
+ "type": "color-background"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "id": 4,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Color Background Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "basic",
+ "type": "color-background"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 16
+ },
+ "id": 5,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Color Background Solid Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "type": "some-other-mode"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 16
+ },
+ "id": 6,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Other Display Mode",
+ "type": "table"
+ }
+ ],
+ "preload": false,
+ "refresh": "",
+ "schemaVersion": 40,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.41.json b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.41.json
new file mode 100644
index 00000000000..2d1d083a874
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.41.json
@@ -0,0 +1,375 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations \u0026 Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "basic",
+ "type": "gauge"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Basic Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "gradient",
+ "type": "gauge"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Gradient Gauge Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "lcd",
+ "type": "gauge"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ },
+ "id": 3,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "LCD Gauge Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "gradient",
+ "type": "color-background"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "id": 4,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Color Background Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "mode": "basic",
+ "type": "color-background"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 16
+ },
+ "id": 5,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Color Background Solid Display Mode",
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "cellOptions": {
+ "type": "some-other-mode"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 16
+ },
+ "id": 6,
+ "options": {
+ "showHeader": true
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "refId": "A"
+ }
+ ],
+ "title": "Other Display Mode",
+ "type": "table"
+ }
+ ],
+ "preload": false,
+ "refresh": "",
+ "schemaVersion": 41,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.39.json b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.39.json
index 136a2fb9d40..19b3b5d79f8 100644
--- a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.39.json
+++ b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.39.json
@@ -147,7 +147,19 @@
"from": "now-6h",
"to": "now"
},
- "timepicker": {},
+ "timepicker": {
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
"timezone": "utc",
"title": "New dashboard",
"version": 0,
diff --git a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.40.json b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.40.json
index 4217f847e8c..63b0959daa1 100644
--- a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.40.json
+++ b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.40.json
@@ -147,7 +147,19 @@
"from": "now-6h",
"to": "now"
},
- "timepicker": {},
+ "timepicker": {
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
"timezone": "utc",
"title": "New dashboard",
"version": 0,
diff --git a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.41.json b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.41.json
new file mode 100644
index 00000000000..be300a117c6
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.41.json
@@ -0,0 +1,155 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations \u0026 Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisBorderShow": false,
+ "axisCenteredZero": false,
+ "axisColorMode": "text",
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "barWidthFactor": 0.6,
+ "drawStyle": "line",
+ "fillOpacity": 0,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "insertNulls": false,
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "hideZeros": false,
+ "mode": "single",
+ "sort": "none"
+ }
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "queryType": "randomWalk",
+ "refId": "A"
+ },
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "queryType": "randomWalk",
+ "refId": "B"
+ }
+ ],
+ "title": "Panel Title",
+ "transformations": [
+ {
+ "id": "timeSeriesTable",
+ "options": {
+ "A": {
+ "stat": "mean"
+ },
+ "B": {
+ "stat": "max"
+ }
+ }
+ }
+ ],
+ "type": "timeseries"
+ }
+ ],
+ "preload": false,
+ "refresh": "",
+ "schemaVersion": 41,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.40.json b/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.40.json
index a8c67a1e80d..5c4ad99f35c 100644
--- a/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.40.json
+++ b/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.40.json
@@ -126,7 +126,19 @@
"from": "now-6h",
"to": "now"
},
- "timepicker": {},
+ "timepicker": {
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
"timezone": "utc",
"title": "New dashboard",
"version": 0,
diff --git a/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.39.json b/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.41.json
similarity index 98%
rename from pkg/apis/dashboard/migration/testdata/output/39.refresh_true.39.json
rename to pkg/apis/dashboard/migration/testdata/output/39.refresh_true.41.json
index 6c0cb4d1974..8c6dfe8ba06 100644
--- a/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.39.json
+++ b/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.41.json
@@ -116,8 +116,8 @@
}
],
"preload": false,
- "refresh": true,
- "schemaVersion": 39,
+ "refresh": "",
+ "schemaVersion": 41,
"tags": [],
"templating": {
"list": []
diff --git a/pkg/apis/dashboard/migration/testdata/output/40.time_picker_time_options.41.json b/pkg/apis/dashboard/migration/testdata/output/40.time_picker_time_options.41.json
new file mode 100644
index 00000000000..8c6dfe8ba06
--- /dev/null
+++ b/pkg/apis/dashboard/migration/testdata/output/40.time_picker_time_options.41.json
@@ -0,0 +1,134 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations \u0026 Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisBorderShow": false,
+ "axisCenteredZero": false,
+ "axisColorMode": "text",
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "barWidthFactor": 0.6,
+ "drawStyle": "line",
+ "fillOpacity": 0,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "insertNulls": false,
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "hideZeros": false,
+ "mode": "single",
+ "sort": "none"
+ }
+ },
+ "pluginVersion": "11.5.0-81438",
+ "targets": [
+ {
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "queryType": "randomWalk",
+ "refId": "A"
+ }
+ ],
+ "title": "Panel Title",
+ "type": "timeseries"
+ }
+ ],
+ "preload": false,
+ "refresh": "",
+ "schemaVersion": 41,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "utc",
+ "title": "New dashboard",
+ "version": 0,
+ "weekStart": ""
+}
\ No newline at end of file
diff --git a/pkg/apis/dashboard/utils.go b/pkg/apis/dashboard/utils.go
new file mode 100644
index 00000000000..a7fa10af2d0
--- /dev/null
+++ b/pkg/apis/dashboard/utils.go
@@ -0,0 +1,51 @@
+package dashboard
+
+import (
+ "strings"
+
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+var PluginIDRepoName = "plugin"
+var fileProvisionedRepoPrefix = "file:"
+
+// ProvisionedFileNameWithPrefix adds the `file:` prefix to the
+// provisioner name, to be used as the annotation for dashboards
+// provisioned from files
+func ProvisionedFileNameWithPrefix(name string) string {
+ if name == "" {
+ return ""
+ }
+
+ return fileProvisionedRepoPrefix + name
+}
+
+// GetProvisionedFileNameFromMeta returns the provisioner name
+// from a given annotation string, which is in the form file:
+func GetProvisionedFileNameFromMeta(annotation string) (string, bool) {
+ return strings.CutPrefix(annotation, fileProvisionedRepoPrefix)
+}
+
+// SetPluginIDMeta sets the repo name to "plugin" and the path to the plugin ID
+func SetPluginIDMeta(obj unstructured.Unstructured, pluginID string) {
+ if pluginID == "" {
+ return
+ }
+
+ annotations := obj.GetAnnotations()
+ if annotations == nil {
+ annotations = map[string]string{}
+ }
+ annotations[utils.AnnoKeyRepoName] = PluginIDRepoName
+ annotations[utils.AnnoKeyRepoPath] = pluginID
+ obj.SetAnnotations(annotations)
+}
+
+// GetPluginIDFromMeta returns the plugin ID from the meta if the repo name is "plugin"
+func GetPluginIDFromMeta(obj utils.GrafanaMetaAccessor) string {
+ if obj.GetRepositoryName() == PluginIDRepoName {
+ return obj.GetRepositoryPath()
+ }
+ return ""
+}
diff --git a/pkg/apis/dashboard/v1alpha1/conversion.go b/pkg/apis/dashboard/v1alpha1/conversion.go
index 358c482c4e3..c01b13db29c 100644
--- a/pkg/apis/dashboard/v1alpha1/conversion.go
+++ b/pkg/apis/dashboard/v1alpha1/conversion.go
@@ -1,8 +1,6 @@
package v1alpha1
import (
- "errors"
-
conversion "k8s.io/apimachinery/pkg/conversion"
klog "k8s.io/klog/v2"
@@ -15,12 +13,7 @@ func Convert_v0alpha1_Unstructured_To_v1alpha1_DashboardSpec(in *common.Unstruct
out.Unstructured = *in
err := migration.Migrate(out.Unstructured.Object, schemaversion.LATEST_VERSION)
if err != nil {
- minErr := &schemaversion.MinimumVersionError{}
- if errors.As(err, &minErr) {
- out.Unstructured.Object["__migrationError"] = err.Error()
- } else {
- return err
- }
+ return err
}
t, ok := out.Unstructured.Object["title"].(string)
diff --git a/pkg/apis/dashboard/v2alpha1/conversion.go b/pkg/apis/dashboard/v2alpha1/conversion.go
index 280cac67b14..9a1818fc0c3 100644
--- a/pkg/apis/dashboard/v2alpha1/conversion.go
+++ b/pkg/apis/dashboard/v2alpha1/conversion.go
@@ -1,8 +1,6 @@
package v2alpha1
import (
- "errors"
-
conversion "k8s.io/apimachinery/pkg/conversion"
klog "k8s.io/klog/v2"
@@ -15,12 +13,7 @@ func Convert_v0alpha1_Unstructured_To_v2alpha1_DashboardSpec(in *common.Unstruct
out.Unstructured = *in
err := migration.Migrate(out.Unstructured.Object, schemaversion.LATEST_VERSION)
if err != nil {
- minErr := &schemaversion.MinimumVersionError{}
- if errors.As(err, &minErr) {
- out.Unstructured.Object["__migrationError"] = err.Error()
- } else {
- return err
- }
+ return err
}
t, ok := out.Unstructured.Object["title"].(string)
diff --git a/pkg/apis/provisioning/v0alpha1/jobs.go b/pkg/apis/provisioning/v0alpha1/jobs.go
index a4e01d98743..86643858a9f 100644
--- a/pkg/apis/provisioning/v0alpha1/jobs.go
+++ b/pkg/apis/provisioning/v0alpha1/jobs.go
@@ -136,6 +136,7 @@ func (in *JobStatus) ToSyncStatus(jobId string) SyncStatus {
type JobResourceSummary struct {
Group string `json:"group,omitempty"`
Resource string `json:"resource,omitempty"`
+ Total int64 `json:"total,omitempty"` // the count (if known)
Create int64 `json:"create,omitempty"`
Update int64 `json:"update,omitempty"`
diff --git a/pkg/apis/provisioning/v0alpha1/register.go b/pkg/apis/provisioning/v0alpha1/register.go
index 1438186062a..9e5eec0b6cc 100644
--- a/pkg/apis/provisioning/v0alpha1/register.go
+++ b/pkg/apis/provisioning/v0alpha1/register.go
@@ -39,8 +39,6 @@ var RepositoryResourceInfo = utils.NewResourceInfo(GROUP, VERSION,
switch m.Spec.Type {
case LocalRepositoryType:
target = m.Spec.Local.Path
- case S3RepositoryType:
- target = m.Spec.S3.Bucket
case GitHubRepositoryType:
target = m.Spec.GitHub.URL
}
diff --git a/pkg/apis/provisioning/v0alpha1/settings.go b/pkg/apis/provisioning/v0alpha1/settings.go
index 65ad68fd7dc..63d202f7ee1 100644
--- a/pkg/apis/provisioning/v0alpha1/settings.go
+++ b/pkg/apis/provisioning/v0alpha1/settings.go
@@ -9,6 +9,11 @@ import (
type RepositoryViewList struct {
metav1.TypeMeta `json:",inline"`
+ // The backend is using legacy storage
+ // FIXME: Not sure where this should be exposed... but we need it somewhere
+ // The UI should force the onboarding workflow when this is true
+ LegacyStorage bool `json:"legacyStorage,omitempty"`
+
// +mapType=atomic
Items []RepositoryView `json:"items"`
}
diff --git a/pkg/apis/provisioning/v0alpha1/types.go b/pkg/apis/provisioning/v0alpha1/types.go
index 203c59d4dd3..ce2a7ef1dcc 100644
--- a/pkg/apis/provisioning/v0alpha1/types.go
+++ b/pkg/apis/provisioning/v0alpha1/types.go
@@ -23,15 +23,6 @@ type LocalRepositoryConfig struct {
Path string `json:"path,omitempty"`
}
-type S3RepositoryConfig struct {
- Region string `json:"region,omitempty"`
- Bucket string `json:"bucket,omitempty"`
-
- // TODO: Add ACL?
- // TODO: Encryption??
- // TODO: How do we define access? Secrets?
-}
-
// Workflow used for changes in the repository.
// +enum
type Workflow string
@@ -51,10 +42,14 @@ type GitHubRepositoryConfig struct {
// By default, this is the main branch.
Branch string `json:"branch,omitempty"`
- // Token for accessing the repository.
+ // Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.
// TODO: this should be part of secrets and a simple reference.
Token string `json:"token,omitempty"`
+ // Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.
+ // +listType=atomic
+ EncryptedToken []byte `json:"encryptedToken,omitempty"`
+
// Workflow allowed for changes to the repository.
// The order is relevant for defining the precedence of the workflows.
// Possible values: pull-request, branch, push.
@@ -72,7 +67,6 @@ type RepositoryType string
// RepositoryType values
const (
LocalRepositoryType RepositoryType = "local"
- S3RepositoryType RepositoryType = "s3"
GitHubRepositoryType RepositoryType = "github"
)
@@ -93,15 +87,11 @@ type RepositorySpec struct {
Type RepositoryType `json:"type"`
// The repository on the local file system.
- // Mutually exclusive with local | s3 | github.
+ // Mutually exclusive with local | github.
Local *LocalRepositoryConfig `json:"local,omitempty"`
- // The repository in an S3 bucket.
- // Mutually exclusive with local | s3 | github.
- S3 *S3RepositoryConfig `json:"s3,omitempty"`
-
// The repository on GitHub.
- // Mutually exclusive with local | s3 | github.
+ // Mutually exclusive with local | github.
// TODO: github or just 'git'??
GitHub *GitHubRepositoryConfig `json:"github,omitempty"`
}
@@ -203,6 +193,7 @@ type WebhookStatus struct {
ID int64 `json:"id,omitempty"`
URL string `json:"url,omitempty"`
Secret string `json:"secret,omitempty"`
+ EncryptedSecret []byte `json:"encryptedSecret,omitempty"`
SubscribedEvents []string `json:"subscribedEvents,omitempty"`
}
diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go
index 92a4bc345c0..d46e1aab264 100644
--- a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go
@@ -93,6 +93,11 @@ func (in *FileList) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *GitHubRepositoryConfig) DeepCopyInto(out *GitHubRepositoryConfig) {
*out = *in
+ if in.EncryptedToken != nil {
+ in, out := &in.EncryptedToken, &out.EncryptedToken
+ *out = make([]byte, len(*in))
+ copy(*out, *in)
+ }
if in.Workflows != nil {
in, out := &in.Workflows, &out.Workflows
*out = make([]Workflow, len(*in))
@@ -449,11 +454,6 @@ func (in *RepositorySpec) DeepCopyInto(out *RepositorySpec) {
*out = new(LocalRepositoryConfig)
**out = **in
}
- if in.S3 != nil {
- in, out := &in.S3, &out.S3
- *out = new(S3RepositoryConfig)
- **out = **in
- }
if in.GitHub != nil {
in, out := &in.GitHub, &out.GitHub
*out = new(GitHubRepositoryConfig)
@@ -716,22 +716,6 @@ func (in *ResourceWrapper) DeepCopyObject() runtime.Object {
return nil
}
-// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *S3RepositoryConfig) DeepCopyInto(out *S3RepositoryConfig) {
- *out = *in
- return
-}
-
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new S3RepositoryConfig.
-func (in *S3RepositoryConfig) DeepCopy() *S3RepositoryConfig {
- if in == nil {
- return nil
- }
- out := new(S3RepositoryConfig)
- in.DeepCopyInto(out)
- return out
-}
-
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SyncJobOptions) DeepCopyInto(out *SyncJobOptions) {
*out = *in
@@ -852,6 +836,11 @@ func (in *WebhookResponse) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *WebhookStatus) DeepCopyInto(out *WebhookStatus) {
*out = *in
+ if in.EncryptedSecret != nil {
+ in, out := &in.EncryptedSecret, &out.EncryptedSecret
+ *out = make([]byte, len(*in))
+ copy(*out, *in)
+ }
if in.SubscribedEvents != nil {
in, out := &in.SubscribedEvents, &out.SubscribedEvents
*out = make([]string, len(*in))
diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go
index ba44abc7892..61123288566 100644
--- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go
+++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go
@@ -43,7 +43,6 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceStats": schema_pkg_apis_provisioning_v0alpha1_ResourceStats(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceType": schema_pkg_apis_provisioning_v0alpha1_ResourceType(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceWrapper": schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref),
- "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.S3RepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_S3RepositoryConfig(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncJobOptions": schema_pkg_apis_provisioning_v0alpha1_SyncJobOptions(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncOptions": schema_pkg_apis_provisioning_v0alpha1_SyncOptions(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncStatus": schema_pkg_apis_provisioning_v0alpha1_SyncStatus(ref),
@@ -255,11 +254,23 @@ func schema_pkg_apis_provisioning_v0alpha1_GitHubRepositoryConfig(ref common.Ref
},
"token": {
SchemaProps: spec.SchemaProps{
- Description: "Token for accessing the repository.",
+ Description: "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.",
Type: []string{"string"},
Format: "",
},
},
+ "encryptedToken": {
+ VendorExtensible: spec.VendorExtensible{
+ Extensions: spec.Extensions{
+ "x-kubernetes-list-type": "atomic",
+ },
+ },
+ SchemaProps: spec.SchemaProps{
+ Description: "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.",
+ Type: []string{"string"},
+ Format: "byte",
+ },
+ },
"workflows": {
SchemaProps: spec.SchemaProps{
Description: "Workflow allowed for changes to the repository. The order is relevant for defining the precedence of the workflows. Possible values: pull-request, branch, push.",
@@ -557,6 +568,12 @@ func schema_pkg_apis_provisioning_v0alpha1_JobResourceSummary(ref common.Referen
Format: "",
},
},
+ "total": {
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"integer"},
+ Format: "int64",
+ },
+ },
"create": {
SchemaProps: spec.SchemaProps{
Type: []string{"integer"},
@@ -971,28 +988,22 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositorySpec(ref common.ReferenceCa
},
"type": {
SchemaProps: spec.SchemaProps{
- Description: "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`\n - `\"s3\"`",
+ Description: "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`",
Default: "",
Type: []string{"string"},
Format: "",
- Enum: []interface{}{"github", "local", "s3"},
+ Enum: []interface{}{"github", "local"},
},
},
"local": {
SchemaProps: spec.SchemaProps{
- Description: "The repository on the local file system. Mutually exclusive with local | s3 | github.",
+ Description: "The repository on the local file system. Mutually exclusive with local | github.",
Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig"),
},
},
- "s3": {
- SchemaProps: spec.SchemaProps{
- Description: "The repository in an S3 bucket. Mutually exclusive with local | s3 | github.",
- Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.S3RepositoryConfig"),
- },
- },
"github": {
SchemaProps: spec.SchemaProps{
- Description: "The repository on GitHub. Mutually exclusive with local | s3 | github.",
+ Description: "The repository on GitHub. Mutually exclusive with local | github.",
Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig"),
},
},
@@ -1001,7 +1012,7 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositorySpec(ref common.ReferenceCa
},
},
Dependencies: []string{
- "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.S3RepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncOptions"},
+ "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncOptions"},
}
}
@@ -1100,11 +1111,11 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryView(ref common.ReferenceCa
},
"type": {
SchemaProps: spec.SchemaProps{
- Description: "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`\n - `\"s3\"`",
+ Description: "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`",
Default: "",
Type: []string{"string"},
Format: "",
- Enum: []interface{}{"github", "local", "s3"},
+ Enum: []interface{}{"github", "local"},
},
},
"target": {
@@ -1144,6 +1155,13 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryViewList(ref common.Referen
Format: "",
},
},
+ "legacyStorage": {
+ SchemaProps: spec.SchemaProps{
+ Description: "The backend is using legacy storage FIXME: Not sure where this should be exposed... but we need it somewhere The UI should force the onboarding workflow when this is true",
+ Type: []string{"boolean"},
+ Format: "",
+ },
+ },
"items": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
@@ -1579,30 +1597,6 @@ func schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref common.ReferenceC
}
}
-func schema_pkg_apis_provisioning_v0alpha1_S3RepositoryConfig(ref common.ReferenceCallback) common.OpenAPIDefinition {
- return common.OpenAPIDefinition{
- Schema: spec.Schema{
- SchemaProps: spec.SchemaProps{
- Type: []string{"object"},
- Properties: map[string]spec.Schema{
- "region": {
- SchemaProps: spec.SchemaProps{
- Type: []string{"string"},
- Format: "",
- },
- },
- "bucket": {
- SchemaProps: spec.SchemaProps{
- Type: []string{"string"},
- Format: "",
- },
- },
- },
- },
- },
- }
-}
-
func schema_pkg_apis_provisioning_v0alpha1_SyncJobOptions(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -1884,6 +1878,12 @@ func schema_pkg_apis_provisioning_v0alpha1_WebhookStatus(ref common.ReferenceCal
Format: "",
},
},
+ "encryptedSecret": {
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"string"},
+ Format: "byte",
+ },
+ },
"subscribedEvents": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
diff --git a/pkg/apiserver/endpoints/responsewriter/responsewriter.go b/pkg/apiserver/endpoints/responsewriter/responsewriter.go
index a076340d7bb..824cc05a17e 100644
--- a/pkg/apiserver/endpoints/responsewriter/responsewriter.go
+++ b/pkg/apiserver/endpoints/responsewriter/responsewriter.go
@@ -37,11 +37,12 @@ func WrapHandler(handler http.Handler) func(req *http.Request) (*http.Response,
if err != nil {
return nil, err
}
- defer cancel()
+ // The cancel happens in the goroutine we spawn, so as to not cancel it too early.
req = req.WithContext(ctx) // returns a shallow copy, so we can't do it as part of the adapter.
w := NewAdapter(req)
go func() {
+ defer cancel()
handler.ServeHTTP(w, req)
if err := w.CloseWriter(); err != nil {
klog.Errorf("error closing writer: %v", err)
diff --git a/pkg/generated/applyconfiguration/provisioning/v0alpha1/githubrepositoryconfig.go b/pkg/generated/applyconfiguration/provisioning/v0alpha1/githubrepositoryconfig.go
index 53f85b71fa8..3003228ef15 100644
--- a/pkg/generated/applyconfiguration/provisioning/v0alpha1/githubrepositoryconfig.go
+++ b/pkg/generated/applyconfiguration/provisioning/v0alpha1/githubrepositoryconfig.go
@@ -14,6 +14,7 @@ type GitHubRepositoryConfigApplyConfiguration struct {
URL *string `json:"url,omitempty"`
Branch *string `json:"branch,omitempty"`
Token *string `json:"token,omitempty"`
+ EncryptedToken []byte `json:"encryptedToken,omitempty"`
Workflows []provisioningv0alpha1.Workflow `json:"workflows,omitempty"`
GenerateDashboardPreviews *bool `json:"generateDashboardPreviews,omitempty"`
}
@@ -48,6 +49,16 @@ func (b *GitHubRepositoryConfigApplyConfiguration) WithToken(value string) *GitH
return b
}
+// WithEncryptedToken adds the given value to the EncryptedToken field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, values provided by each call will be appended to the EncryptedToken field.
+func (b *GitHubRepositoryConfigApplyConfiguration) WithEncryptedToken(values ...byte) *GitHubRepositoryConfigApplyConfiguration {
+ for i := range values {
+ b.EncryptedToken = append(b.EncryptedToken, values[i])
+ }
+ return b
+}
+
// WithWorkflows adds the given value to the Workflows field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, values provided by each call will be appended to the Workflows field.
diff --git a/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryspec.go b/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryspec.go
index 8cdedec2731..b0d4769286d 100644
--- a/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryspec.go
+++ b/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryspec.go
@@ -17,7 +17,6 @@ type RepositorySpecApplyConfiguration struct {
Sync *SyncOptionsApplyConfiguration `json:"sync,omitempty"`
Type *provisioningv0alpha1.RepositoryType `json:"type,omitempty"`
Local *LocalRepositoryConfigApplyConfiguration `json:"local,omitempty"`
- S3 *S3RepositoryConfigApplyConfiguration `json:"s3,omitempty"`
GitHub *GitHubRepositoryConfigApplyConfiguration `json:"github,omitempty"`
}
@@ -75,14 +74,6 @@ func (b *RepositorySpecApplyConfiguration) WithLocal(value *LocalRepositoryConfi
return b
}
-// WithS3 sets the S3 field in the declarative configuration to the given value
-// and returns the receiver, so that objects can be built by chaining "With" function invocations.
-// If called multiple times, the S3 field is set to the value of the last call.
-func (b *RepositorySpecApplyConfiguration) WithS3(value *S3RepositoryConfigApplyConfiguration) *RepositorySpecApplyConfiguration {
- b.S3 = value
- return b
-}
-
// WithGitHub sets the GitHub field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the GitHub field is set to the value of the last call.
diff --git a/pkg/generated/applyconfiguration/provisioning/v0alpha1/s3repositoryconfig.go b/pkg/generated/applyconfiguration/provisioning/v0alpha1/s3repositoryconfig.go
deleted file mode 100644
index b33e875bd91..00000000000
--- a/pkg/generated/applyconfiguration/provisioning/v0alpha1/s3repositoryconfig.go
+++ /dev/null
@@ -1,34 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-
-// Code generated by applyconfiguration-gen. DO NOT EDIT.
-
-package v0alpha1
-
-// S3RepositoryConfigApplyConfiguration represents a declarative configuration of the S3RepositoryConfig type for use
-// with apply.
-type S3RepositoryConfigApplyConfiguration struct {
- Region *string `json:"region,omitempty"`
- Bucket *string `json:"bucket,omitempty"`
-}
-
-// S3RepositoryConfigApplyConfiguration constructs a declarative configuration of the S3RepositoryConfig type for use with
-// apply.
-func S3RepositoryConfig() *S3RepositoryConfigApplyConfiguration {
- return &S3RepositoryConfigApplyConfiguration{}
-}
-
-// WithRegion sets the Region field in the declarative configuration to the given value
-// and returns the receiver, so that objects can be built by chaining "With" function invocations.
-// If called multiple times, the Region field is set to the value of the last call.
-func (b *S3RepositoryConfigApplyConfiguration) WithRegion(value string) *S3RepositoryConfigApplyConfiguration {
- b.Region = &value
- return b
-}
-
-// WithBucket sets the Bucket field in the declarative configuration to the given value
-// and returns the receiver, so that objects can be built by chaining "With" function invocations.
-// If called multiple times, the Bucket field is set to the value of the last call.
-func (b *S3RepositoryConfigApplyConfiguration) WithBucket(value string) *S3RepositoryConfigApplyConfiguration {
- b.Bucket = &value
- return b
-}
diff --git a/pkg/generated/applyconfiguration/provisioning/v0alpha1/webhookstatus.go b/pkg/generated/applyconfiguration/provisioning/v0alpha1/webhookstatus.go
index e42f7b9c134..d550e4fc753 100644
--- a/pkg/generated/applyconfiguration/provisioning/v0alpha1/webhookstatus.go
+++ b/pkg/generated/applyconfiguration/provisioning/v0alpha1/webhookstatus.go
@@ -10,6 +10,7 @@ type WebhookStatusApplyConfiguration struct {
ID *int64 `json:"id,omitempty"`
URL *string `json:"url,omitempty"`
Secret *string `json:"secret,omitempty"`
+ EncryptedSecret []byte `json:"encryptedSecret,omitempty"`
SubscribedEvents []string `json:"subscribedEvents,omitempty"`
}
@@ -43,6 +44,16 @@ func (b *WebhookStatusApplyConfiguration) WithSecret(value string) *WebhookStatu
return b
}
+// WithEncryptedSecret adds the given value to the EncryptedSecret field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, values provided by each call will be appended to the EncryptedSecret field.
+func (b *WebhookStatusApplyConfiguration) WithEncryptedSecret(values ...byte) *WebhookStatusApplyConfiguration {
+ for i := range values {
+ b.EncryptedSecret = append(b.EncryptedSecret, values[i])
+ }
+ return b
+}
+
// WithSubscribedEvents adds the given value to the SubscribedEvents field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, values provided by each call will be appended to the SubscribedEvents field.
diff --git a/pkg/generated/applyconfiguration/utils.go b/pkg/generated/applyconfiguration/utils.go
index bae4891ada9..3b96c0d01fd 100644
--- a/pkg/generated/applyconfiguration/utils.go
+++ b/pkg/generated/applyconfiguration/utils.go
@@ -34,8 +34,6 @@ func ForKind(kind schema.GroupVersionKind) interface{} {
return &provisioningv0alpha1.RepositoryStatusApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("ResourceCount"):
return &provisioningv0alpha1.ResourceCountApplyConfiguration{}
- case v0alpha1.SchemeGroupVersion.WithKind("S3RepositoryConfig"):
- return &provisioningv0alpha1.S3RepositoryConfigApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("SyncOptions"):
return &provisioningv0alpha1.SyncOptionsApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("SyncStatus"):
diff --git a/pkg/infra/metrics/service.go b/pkg/infra/metrics/service.go
index f704ab9248b..99c1e048d4f 100644
--- a/pkg/infra/metrics/service.go
+++ b/pkg/infra/metrics/service.go
@@ -26,12 +26,13 @@ func (lw *logWrapper) Println(v ...any) {
lw.logger.Info("graphite metric bridge", v...)
}
-func ProvideService(cfg *setting.Cfg, reg prometheus.Registerer) (*InternalMetricsService, error) {
+func ProvideService(cfg *setting.Cfg, reg prometheus.Registerer, gatherer prometheus.Gatherer) (*InternalMetricsService, error) {
initMetricVars(reg)
initFrontendMetrics(reg)
s := &InternalMetricsService{
- Cfg: cfg,
+ Cfg: cfg,
+ gatherer: gatherer,
}
return s, s.readSettings()
}
@@ -41,6 +42,7 @@ type InternalMetricsService struct {
intervalSeconds int64
graphiteCfg *graphitebridge.Config
+ gatherer prometheus.Gatherer
}
func (im *InternalMetricsService) Run(ctx context.Context) error {
diff --git a/pkg/infra/metrics/settings.go b/pkg/infra/metrics/settings.go
index 54715db249e..587956158f2 100644
--- a/pkg/infra/metrics/settings.go
+++ b/pkg/infra/metrics/settings.go
@@ -5,8 +5,6 @@ import (
"strings"
"time"
- "github.com/prometheus/client_golang/prometheus"
-
"github.com/grafana/grafana/pkg/infra/metrics/graphitebridge"
)
@@ -40,7 +38,7 @@ func (im *InternalMetricsService) parseGraphiteSettings() error {
URL: address,
Prefix: graphiteSection.Key("prefix").MustString("prod.grafana.%(instance_name)s"),
CountersAsDelta: true,
- Gatherer: prometheus.DefaultGatherer,
+ Gatherer: im.gatherer,
Interval: time.Duration(im.intervalSeconds) * time.Second,
Timeout: 10 * time.Second,
Logger: &logWrapper{logger: metricsLogger},
diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go
index 38e17ae6219..526c5a66056 100644
--- a/pkg/kinds/dashboard/dashboard_spec_gen.go
+++ b/pkg/kinds/dashboard/dashboard_spec_gen.go
@@ -48,8 +48,6 @@ type TimePickerConfig struct {
Hidden *bool `json:"hidden,omitempty"`
// Interval options available in the refresh picker dropdown.
RefreshIntervals []string `json:"refresh_intervals,omitempty"`
- // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.
- TimeOptions []string `json:"time_options,omitempty"`
// Quick ranges for time picker.
QuickRanges []TimeOption `json:"quick_ranges,omitempty"`
// Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.
@@ -61,7 +59,6 @@ func NewTimePickerConfig() *TimePickerConfig {
return &TimePickerConfig{
Hidden: (func(input bool) *bool { return &input })(false),
RefreshIntervals: []string{"5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"},
- TimeOptions: []string{"5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"},
}
}
@@ -927,7 +924,7 @@ func NewSpec() *Spec {
Editable: (func(input bool) *bool { return &input })(true),
GraphTooltip: (func(input DashboardCursorSync) *DashboardCursorSync { return &input })(DashboardCursorSyncOff),
FiscalYearStartMonth: (func(input uint8) *uint8 { return &input })(0),
- SchemaVersion: 39,
+ SchemaVersion: 41,
}
}
diff --git a/pkg/login/social/connectors/azuread_oauth.go b/pkg/login/social/connectors/azuread_oauth.go
index 0f4a72ed5a7..8ae1f3380ab 100644
--- a/pkg/login/social/connectors/azuread_oauth.go
+++ b/pkg/login/social/connectors/azuread_oauth.go
@@ -88,10 +88,17 @@ type keySetJWKS struct {
}
func NewAzureADProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles, cache remotecache.CacheStorage) *SocialAzureAD {
+ s := newSocialBase(social.AzureADProviderName, orgRoleMapper, info, features, cfg)
+
+ allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey])
+ if err != nil {
+ s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.AzureADProviderName, "error", err)
+ }
+
provider := &SocialAzureAD{
- SocialBase: newSocialBase(social.AzureADProviderName, orgRoleMapper, info, features, cfg),
+ SocialBase: s,
cache: cache,
- allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]),
+ allowedOrganizations: allowedOrganizations,
forceUseGraphAPI: MustBool(info.Extra[forceUseGraphAPIKey], ExtraAzureADSettingKeys[forceUseGraphAPIKey].DefaultValue.(bool)),
}
@@ -236,7 +243,7 @@ func (s *SocialAzureAD) managedIdentityCallback(ctx context.Context) (string, er
}
func (s *SocialAzureAD) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
- newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
+ newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.AzureADProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
@@ -250,7 +257,12 @@ func (s *SocialAzureAD) Reload(ctx context.Context, settings ssoModels.SSOSettin
appendUniqueScope(s.Config, social.OfflineAccessScope)
}
- s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey])
+ allowedOrganizations, err := util.SplitStringWithError(newInfo.Extra[allowedOrganizationsKey])
+ if err != nil {
+ s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.AzureADProviderName, "error", err)
+ }
+
+ s.allowedOrganizations = allowedOrganizations
s.forceUseGraphAPI = MustBool(newInfo.Extra[forceUseGraphAPIKey], false)
return nil
diff --git a/pkg/login/social/connectors/common.go b/pkg/login/social/connectors/common.go
index 255b96742e8..15fc590f359 100644
--- a/pkg/login/social/connectors/common.go
+++ b/pkg/login/social/connectors/common.go
@@ -2,6 +2,7 @@ package connectors
import (
"context"
+ "errors"
"fmt"
"io"
"net/http"
@@ -13,6 +14,7 @@ import (
"github.com/mitchellh/mapstructure"
"golang.org/x/oauth2"
+ "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
@@ -165,9 +167,25 @@ func MustBool(value any, defaultValue bool) bool {
return result
}
+// CreateOAuthInfoFromKeyValuesWithLogging creates an OAuthInfo struct from a map[string]any using mapstructure
+// it puts all extra key values into OAuthInfo's Extra map.
+// It logs as errors any parsing errors that are not critical
+func CreateOAuthInfoFromKeyValuesWithLogging(l log.Logger, provider string, settingsKV map[string]any) (*social.OAuthInfo, error) {
+ parsingWarns := []error{}
+ info, err := createOAuthInfoFromKeyValues(settingsKV, &parsingWarns)
+ if len(parsingWarns) > 0 {
+ l.Error("Invalid auth configuration setting", "error", errors.Join(parsingWarns...), "provider", provider)
+ }
+ return info, err
+}
+
// CreateOAuthInfoFromKeyValues creates an OAuthInfo struct from a map[string]any using mapstructure
// it puts all extra key values into OAuthInfo's Extra map
func CreateOAuthInfoFromKeyValues(settingsKV map[string]any) (*social.OAuthInfo, error) {
+ return createOAuthInfoFromKeyValues(settingsKV, nil)
+}
+
+func createOAuthInfoFromKeyValues(settingsKV map[string]any, parsingWarns *[]error) (*social.OAuthInfo, error) {
emptyStrToSliceDecodeHook := func(from reflect.Type, to reflect.Type, data any) (any, error) {
if from.Kind() == reflect.String && to.Kind() == reflect.Slice {
strData, ok := data.(string)
@@ -178,7 +196,12 @@ func CreateOAuthInfoFromKeyValues(settingsKV map[string]any) (*social.OAuthInfo,
if strData == "" {
return []string{}, nil
}
- return util.SplitString(strData), nil
+
+ splitStr, err := util.SplitStringWithError(strData)
+ if err != nil && parsingWarns != nil {
+ *parsingWarns = append(*parsingWarns, err)
+ }
+ return splitStr, nil
}
return data, nil
}
diff --git a/pkg/login/social/connectors/generic_oauth.go b/pkg/login/social/connectors/generic_oauth.go
index eb4a32f8381..15989c0df93 100644
--- a/pkg/login/social/connectors/generic_oauth.go
+++ b/pkg/login/social/connectors/generic_oauth.go
@@ -53,6 +53,18 @@ type SocialGenericOAuth struct {
}
func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGenericOAuth {
+ s := newSocialBase(social.GenericOAuthProviderName, orgRoleMapper, info, features, cfg)
+
+ teamIds, err := util.SplitStringWithError(info.Extra[teamIdsKey])
+ if err != nil {
+ s.log.Error("Invalid auth configuration setting", "config", teamIdsKey, "provider", social.GenericOAuthProviderName, "error", err)
+ }
+
+ allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey])
+ if err != nil {
+ s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GenericOAuthProviderName, "error", err)
+ }
+
provider := &SocialGenericOAuth{
SocialBase: newSocialBase(social.GenericOAuthProviderName, orgRoleMapper, info, features, cfg),
teamsUrl: info.TeamsUrl,
@@ -63,8 +75,8 @@ func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMa
loginAttributePath: info.Extra[loginAttributePathKey],
idTokenAttributeName: info.Extra[idTokenAttributeNameKey],
teamIdsAttributePath: info.TeamIdsAttributePath,
- teamIds: util.SplitString(info.Extra[teamIdsKey]),
- allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]),
+ teamIds: teamIds,
+ allowedOrganizations: allowedOrganizations,
}
if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) {
@@ -118,7 +130,7 @@ func validateTeamsUrlWhenNotEmpty(info *social.OAuthInfo, requester identity.Req
}
func (s *SocialGenericOAuth) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
- newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
+ newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GenericOAuthProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
@@ -128,6 +140,15 @@ func (s *SocialGenericOAuth) Reload(ctx context.Context, settings ssoModels.SSOS
s.updateInfo(ctx, social.GenericOAuthProviderName, newInfo)
+ teamIds, err := util.SplitStringWithError(newInfo.Extra[teamIdsKey])
+ if err != nil {
+ s.log.Error("Invalid auth configuration setting", "config", teamIdsKey, "provider", social.GenericOAuthProviderName, "error", err)
+ }
+ allowedOrganizations, err := util.SplitStringWithError(newInfo.Extra[allowedOrganizationsKey])
+ if err != nil {
+ s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GenericOAuthProviderName, "error", err)
+ }
+
s.teamsUrl = newInfo.TeamsUrl
s.emailAttributeName = newInfo.EmailAttributeName
s.emailAttributePath = newInfo.EmailAttributePath
@@ -136,8 +157,8 @@ func (s *SocialGenericOAuth) Reload(ctx context.Context, settings ssoModels.SSOS
s.loginAttributePath = newInfo.Extra[loginAttributePathKey]
s.idTokenAttributeName = newInfo.Extra[idTokenAttributeNameKey]
s.teamIdsAttributePath = newInfo.TeamIdsAttributePath
- s.teamIds = util.SplitString(newInfo.Extra[teamIdsKey])
- s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey])
+ s.teamIds = teamIds
+ s.allowedOrganizations = allowedOrganizations
return nil
}
diff --git a/pkg/login/social/connectors/github_oauth.go b/pkg/login/social/connectors/github_oauth.go
index 124b642f822..f5f0b43b3f3 100644
--- a/pkg/login/social/connectors/github_oauth.go
+++ b/pkg/login/social/connectors/github_oauth.go
@@ -62,13 +62,23 @@ var (
)
func NewGitHubProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGithub {
- teamIdsSplitted := util.SplitString(info.Extra[teamIdsKey])
+ s := newSocialBase(social.GitHubProviderName, orgRoleMapper, info, features, cfg)
+
+ teamIdsSplitted, err := util.SplitStringWithError(info.Extra[teamIdsKey])
+ if err != nil {
+ s.log.Error("Invalid auth configuration setting", "config", teamIdsKey, "provider", social.GitHubProviderName, "error", err)
+ }
teamIds := mustInts(teamIdsSplitted)
+ allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey])
+ if err != nil {
+ s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GitHubProviderName, "error", err)
+ }
+
provider := &SocialGithub{
- SocialBase: newSocialBase(social.GitHubProviderName, orgRoleMapper, info, features, cfg),
+ SocialBase: s,
teamIds: teamIds,
- allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]),
+ allowedOrganizations: allowedOrganizations,
}
if len(teamIdsSplitted) != len(teamIds) {
@@ -117,14 +127,22 @@ func teamIdsNumbersValidator(info *social.OAuthInfo, requester identity.Requeste
}
func (s *SocialGithub) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
- newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
+ newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GitHubProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
- teamIdsSplitted := util.SplitString(newInfo.Extra[teamIdsKey])
+ teamIdsSplitted, err := util.SplitStringWithError(newInfo.Extra[teamIdsKey])
+ if err != nil {
+ s.log.Error("Invalid auth configuration setting", "config", teamIdsKey, "provider", social.GitHubProviderName, "error", err)
+ }
teamIds := mustInts(teamIdsSplitted)
+ allowedOrganizations, err := util.SplitStringWithError(newInfo.Extra[allowedOrganizationsKey])
+ if err != nil {
+ s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GitHubProviderName, "error", err)
+ }
+
if len(teamIdsSplitted) != len(teamIds) {
s.log.Warn("Failed to parse team ids. Team ids must be a list of numbers.", "teamIds", teamIdsSplitted)
}
@@ -135,7 +153,7 @@ func (s *SocialGithub) Reload(ctx context.Context, settings ssoModels.SSOSetting
s.updateInfo(ctx, social.GitHubProviderName, newInfo)
s.teamIds = teamIds
- s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey])
+ s.allowedOrganizations = allowedOrganizations
return nil
}
diff --git a/pkg/login/social/connectors/gitlab_oauth.go b/pkg/login/social/connectors/gitlab_oauth.go
index d51544dd7c7..7497c24d619 100644
--- a/pkg/login/social/connectors/gitlab_oauth.go
+++ b/pkg/login/social/connectors/gitlab_oauth.go
@@ -87,7 +87,7 @@ func (s *SocialGitlab) Validate(ctx context.Context, newSettings ssoModels.SSOSe
}
func (s *SocialGitlab) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
- newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
+ newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GitlabProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
diff --git a/pkg/login/social/connectors/google_oauth.go b/pkg/login/social/connectors/google_oauth.go
index 3044548948b..113b4c0ef5a 100644
--- a/pkg/login/social/connectors/google_oauth.go
+++ b/pkg/login/social/connectors/google_oauth.go
@@ -87,7 +87,7 @@ func (s *SocialGoogle) Validate(ctx context.Context, newSettings ssoModels.SSOSe
}
func (s *SocialGoogle) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
- newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
+ newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GoogleProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
diff --git a/pkg/login/social/connectors/grafana_com_oauth.go b/pkg/login/social/connectors/grafana_com_oauth.go
index 3f016b1f01c..84ea01632d1 100644
--- a/pkg/login/social/connectors/grafana_com_oauth.go
+++ b/pkg/login/social/connectors/grafana_com_oauth.go
@@ -39,15 +39,22 @@ type OrgRecord struct {
}
func NewGrafanaComProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGrafanaCom {
+ s := newSocialBase(social.GrafanaComProviderName, orgRoleMapper, info, features, cfg)
+
// Override necessary settings
info.AuthUrl = cfg.GrafanaComURL + "/oauth2/authorize"
info.TokenUrl = cfg.GrafanaComURL + "/api/oauth2/token"
info.AuthStyle = "inheader"
+ allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey])
+ if err != nil {
+ s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GrafanaComProviderName, "error", err)
+ }
+
provider := &SocialGrafanaCom{
- SocialBase: newSocialBase(social.GrafanaComProviderName, orgRoleMapper, info, features, cfg),
+ SocialBase: s,
url: cfg.GrafanaComURL,
- allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]),
+ allowedOrganizations: allowedOrganizations,
}
if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) {
@@ -80,11 +87,16 @@ func (s *SocialGrafanaCom) Validate(ctx context.Context, newSettings ssoModels.S
}
func (s *SocialGrafanaCom) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
- newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
+ newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GrafanaComProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
+ allowedOrganizations, err := util.SplitStringWithError(newInfo.Extra[allowedOrganizationsKey])
+ if err != nil {
+ s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GrafanaComProviderName, "error", err)
+ }
+
// Override necessary settings
newInfo.AuthUrl = s.cfg.GrafanaComURL + "/oauth2/authorize"
newInfo.TokenUrl = s.cfg.GrafanaComURL + "/api/oauth2/token"
@@ -96,7 +108,7 @@ func (s *SocialGrafanaCom) Reload(ctx context.Context, settings ssoModels.SSOSet
s.updateInfo(ctx, social.GrafanaComProviderName, newInfo)
s.url = s.cfg.GrafanaComURL
- s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey])
+ s.allowedOrganizations = allowedOrganizations
return nil
}
diff --git a/pkg/login/social/connectors/okta_oauth.go b/pkg/login/social/connectors/okta_oauth.go
index b126c2acd1d..ffa3a32a350 100644
--- a/pkg/login/social/connectors/okta_oauth.go
+++ b/pkg/login/social/connectors/okta_oauth.go
@@ -84,7 +84,7 @@ func (s *SocialOkta) Validate(ctx context.Context, newSettings ssoModels.SSOSett
}
func (s *SocialOkta) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
- newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
+ newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.OktaProviderName, settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
diff --git a/pkg/login/social/socialimpl/service.go b/pkg/login/social/socialimpl/service.go
index ddfb9bf7016..65a9a5573cc 100644
--- a/pkg/login/social/socialimpl/service.go
+++ b/pkg/login/social/socialimpl/service.go
@@ -65,7 +65,7 @@ func ProvideService(cfg *setting.Cfg,
continue
}
- info, err := connectors.CreateOAuthInfoFromKeyValues(ssoSetting.Settings)
+ info, err := connectors.CreateOAuthInfoFromKeyValuesWithLogging(ss.log, ssoSetting.Provider, ssoSetting.Settings)
if err != nil {
ss.log.Error("Failed to create OAuthInfo for provider", "error", err, "provider", ssoSetting.Provider)
continue
@@ -85,7 +85,7 @@ func ProvideService(cfg *setting.Cfg,
settingsKVs := convertIniSectionToMap(sec)
- info, err := connectors.CreateOAuthInfoFromKeyValues(settingsKVs)
+ info, err := connectors.CreateOAuthInfoFromKeyValuesWithLogging(ss.log, name, settingsKVs)
if err != nil {
ss.log.Error("Failed to create OAuthInfo for provider", "error", err, "provider", name)
continue
diff --git a/pkg/plugins/instrumentationutils/request_status.go b/pkg/plugins/instrumentationutils/request_status.go
index 9d6cb7fba5e..5e77480ee77 100644
--- a/pkg/plugins/instrumentationutils/request_status.go
+++ b/pkg/plugins/instrumentationutils/request_status.go
@@ -35,7 +35,7 @@ func RequestStatusFromError(err error) RequestStatus {
status = RequestStatusError
if errors.Is(err, context.Canceled) {
status = RequestStatusCancelled
- } else if s, ok := grpcstatus.FromError(err); ok && s.Code() == grpccodes.Canceled {
+ } else if s, ok := grpcstatus.FromError(err); ok && s.Code() == grpccodes.Canceled || s.Code() == grpccodes.DeadlineExceeded {
status = RequestStatusCancelled
}
}
diff --git a/pkg/plugins/manager/loader/finder/local_test.go b/pkg/plugins/manager/loader/finder/local_test.go
index 9664f824186..18946548c56 100644
--- a/pkg/plugins/manager/loader/finder/local_test.go
+++ b/pkg/plugins/manager/loader/finder/local_test.go
@@ -57,6 +57,7 @@ func TestFinder_Find(t *testing.T) {
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -96,8 +97,10 @@ func TestFinder_Find(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -127,8 +130,10 @@ func TestFinder_Find(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -200,8 +205,10 @@ func TestFinder_Find(t *testing.T) {
{Name: "Nginx Datasource", Type: "datasource", Role: "Viewer", Action: "plugins.app:access"},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -238,8 +245,10 @@ func TestFinder_Find(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -269,8 +278,10 @@ func TestFinder_Find(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -300,8 +311,10 @@ func TestFinder_Find(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -340,8 +353,10 @@ func TestFinder_Find(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go
index d39c9c21303..e5d1b260199 100644
--- a/pkg/plugins/manager/loader/loader_test.go
+++ b/pkg/plugins/manager/loader/loader_test.go
@@ -106,6 +106,7 @@ func TestLoader_Load(t *testing.T) {
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -201,8 +202,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -249,8 +252,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -304,8 +309,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -398,8 +405,10 @@ func TestLoader_Load(t *testing.T) {
{Name: "Root Page (react)", Type: "page", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go
index 6b9b8e05ad2..8440cb37a11 100644
--- a/pkg/plugins/models.go
+++ b/pkg/plugins/models.go
@@ -63,6 +63,7 @@ type ExtensionsV2 struct {
AddedComponents []AddedComponent `json:"addedComponents"`
ExposedComponents []ExposedComponent `json:"exposedComponents"`
ExtensionPoints []ExtensionPoint `json:"extensionPoints"`
+ AddedFunctions []AddedFunction `json:"addedFunctions"`
}
type Extensions ExtensionsV2
@@ -76,6 +77,7 @@ func (e *Extensions) UnmarshalJSON(data []byte) error {
e.AddedLinks = extensionsV2.AddedLinks
e.ExposedComponents = extensionsV2.ExposedComponents
e.ExtensionPoints = extensionsV2.ExtensionPoints
+ e.AddedFunctions = extensionsV2.AddedFunctions
return nil
}
@@ -123,6 +125,11 @@ type AddedComponent struct {
Description string `json:"description"`
}
+type AddedFunction struct {
+ Targets []string `json:"targets"`
+ Title string `json:"title"`
+}
+
type ExposedComponent struct {
Id string `json:"id"`
Title string `json:"title"`
@@ -267,6 +274,7 @@ type PluginMetaDTO struct {
Angular AngularMeta `json:"angular"`
MultiValueFilterOperators bool `json:"multiValueFilterOperators"`
LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
+ Extensions Extensions `json:"extensions"`
}
type DataSourceDTO struct {
diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go
index 3239927747b..f815d07c6b1 100644
--- a/pkg/plugins/plugins.go
+++ b/pkg/plugins/plugins.go
@@ -167,6 +167,10 @@ func ReadPluginJSON(reader io.Reader) (JSONData, error) {
plugin.Extensions.AddedComponents = []AddedComponent{}
}
+ if plugin.Extensions.AddedFunctions == nil {
+ plugin.Extensions.AddedFunctions = []AddedFunction{}
+ }
+
if plugin.Extensions.ExposedComponents == nil {
plugin.Extensions.ExposedComponents = []ExposedComponent{}
}
diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go
index b3922755603..2a6f85dee07 100644
--- a/pkg/plugins/plugins_test.go
+++ b/pkg/plugins/plugins_test.go
@@ -56,6 +56,7 @@ func Test_ReadPluginJSON(t *testing.T) {
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
+ AddedFunctions: []AddedFunction{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
@@ -108,8 +109,10 @@ func Test_ReadPluginJSON(t *testing.T) {
Name: "Pie Chart (old)",
Extensions: Extensions{
- AddedLinks: []AddedLink{},
- AddedComponents: []AddedComponent{},
+ AddedLinks: []AddedLink{},
+ AddedComponents: []AddedComponent{},
+ AddedFunctions: []AddedFunction{},
+
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
@@ -143,8 +146,10 @@ func Test_ReadPluginJSON(t *testing.T) {
Type: TypeDataSource,
Extensions: Extensions{
- AddedLinks: []AddedLink{},
- AddedComponents: []AddedComponent{},
+ AddedLinks: []AddedLink{},
+ AddedComponents: []AddedComponent{},
+ AddedFunctions: []AddedFunction{},
+
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
@@ -188,6 +193,9 @@ func Test_ReadPluginJSON(t *testing.T) {
"id": "myorg-extensions-app/component-1/v1"
}
],
+ "addedFunctions": [
+ {"targets": ["foo/bar"], "title":"some hook"}
+ ],
"extensionPoints": [
{
"title": "Extension point 1",
@@ -209,6 +217,7 @@ func Test_ReadPluginJSON(t *testing.T) {
{Title: "Added link 1", Description: "Added link 1 description", Targets: []string{"grafana/dashboard/panel/menu"}},
},
AddedComponents: []AddedComponent{
+
{Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}},
},
ExposedComponents: []ExposedComponent{
@@ -217,6 +226,9 @@ func Test_ReadPluginJSON(t *testing.T) {
ExtensionPoints: []ExtensionPoint{
{Id: "myorg-extensions-app/extensions-point-1/v1", Title: "Extension point 1", Description: "Extension points 1 description"},
},
+ AddedFunctions: []AddedFunction{
+ {Targets: []string{"foo/bar"}, Title: "some hook"},
+ },
},
Dependencies: Dependencies{
@@ -271,6 +283,7 @@ func Test_ReadPluginJSON(t *testing.T) {
AddedComponents: []AddedComponent{
{Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}},
},
+ AddedFunctions: []AddedFunction{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
@@ -301,8 +314,10 @@ func Test_ReadPluginJSON(t *testing.T) {
Type: TypeApp,
Extensions: Extensions{
- AddedLinks: []AddedLink{},
- AddedComponents: []AddedComponent{},
+ AddedLinks: []AddedLink{},
+ AddedComponents: []AddedComponent{},
+ AddedFunctions: []AddedFunction{},
+
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
@@ -332,8 +347,10 @@ func Test_ReadPluginJSON(t *testing.T) {
Type: TypeApp,
Extensions: Extensions{
- AddedLinks: []AddedLink{},
- AddedComponents: []AddedComponent{},
+ AddedLinks: []AddedLink{},
+ AddedComponents: []AddedComponent{},
+ AddedFunctions: []AddedFunction{},
+
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
@@ -371,6 +388,7 @@ func Test_ReadPluginJSON(t *testing.T) {
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
+ AddedFunctions: []AddedFunction{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
diff --git a/pkg/registry/apis/dashboard/legacy/migrate.go b/pkg/registry/apis/dashboard/legacy/migrate.go
index c1236ef4f2d..5a5cb951aa5 100644
--- a/pkg/registry/apis/dashboard/legacy/migrate.go
+++ b/pkg/registry/apis/dashboard/legacy/migrate.go
@@ -14,7 +14,10 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/utils"
dashboard "github.com/grafana/grafana/pkg/apis/dashboard"
folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ "github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
@@ -22,7 +25,6 @@ import (
type MigrateOptions struct {
Namespace string
Store resource.BatchStoreClient
- Writer resource.BatchResourceWriter
LargeObjects apistore.LargeObjectSupport
BlobStore resource.BlobStoreClient
Resources []schema.GroupResource
@@ -36,6 +38,15 @@ type LegacyMigrator interface {
Migrate(ctx context.Context, opts MigrateOptions) (*resource.BatchResponse, error)
}
+// This can migrate Folders, Dashboards and LibraryPanels
+func ProvideLegacyMigrator(
+ sql db.DB, // direct access to tables
+ provisioning provisioning.ProvisioningService, // only needed for dashboard settings
+) LegacyMigrator {
+ dbp := legacysql.NewDatabaseProvider(sql)
+ return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, false)
+}
+
type BlobStoreInfo struct {
Count int64
Size int64
@@ -49,6 +60,9 @@ func (a *dashboardSqlAccess) Migrate(ctx context.Context, opts MigrateOptions) (
if err != nil {
return nil, err
}
+ if opts.Progress == nil {
+ opts.Progress = func(count int, msg string) {} // noop
+ }
// Migrate everything
if len(opts.Resources) < 1 {
diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go
index 6c1553f9bad..0ba1e67cc88 100644
--- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go
+++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go
@@ -23,7 +23,6 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/dashboards"
- "github.com/grafana/grafana/pkg/services/dashboards/service"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/resource"
@@ -312,7 +311,7 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo
ts := time.Unix(origin_ts.Int64, 0)
repo := &utils.ResourceRepositoryInfo{
- Name: origin_name.String,
+ Name: dashboard.ProvisionedFileNameWithPrefix(origin_name.String),
Hash: origin_hash.String,
Timestamp: &ts,
}
@@ -331,7 +330,7 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo
meta.SetRepositoryInfo(repo)
} else if plugin_id.String != "" {
meta.SetRepositoryInfo(&utils.ResourceRepositoryInfo{
- Name: "plugin",
+ Name: dashboard.PluginIDRepoName,
Path: plugin_id.String,
})
}
@@ -427,7 +426,7 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das
out, err := a.dashStore.SaveDashboard(ctx, dashboards.SaveDashboardCommand{
OrgID: orgId,
Message: meta.GetMessage(),
- PluginID: service.GetPluginIDFromMeta(meta),
+ PluginID: dashboard.GetPluginIDFromMeta(meta),
Dashboard: simplejson.NewFromAny(dash.Spec.UnstructuredContent()),
FolderUID: meta.GetFolder(),
Overwrite: true, // already passed the revisionVersion checks!
diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go
new file mode 100644
index 00000000000..7a75f7acf3b
--- /dev/null
+++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go
@@ -0,0 +1,110 @@
+package legacy
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/DATA-DOG/go-sqlmock"
+ "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ "github.com/grafana/grafana/pkg/services/provisioning"
+ "github.com/stretchr/testify/require"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestScanRow(t *testing.T) {
+ mockDB, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer mockDB.Close() // nolint:errcheck
+
+ pathToFile := "path/to/file"
+ provisioner := provisioning.NewProvisioningServiceMock(context.Background())
+ provisioner.GetDashboardProvisionerResolvedPathFunc = func(name string) string { return "provisioner" }
+ store := &dashboardSqlAccess{
+ namespacer: func(_ int64) string { return "default" },
+ provisioning: provisioner,
+ }
+
+ columns := []string{"orgId", "dashboard_id", "name", "folder_uid", "deleted", "plugin_id", "origin_name", "origin_path", "origin_hash", "origin_ts", "created", "createdBy", "createdByID", "updated", "updatedBy", "updatedByID", "version", "message", "data"}
+ id := int64(100)
+ title := "Test Dashboard"
+ folderUID := "folder123"
+ timestamp := time.Now()
+ k8sTimestamp := v1.Time{Time: timestamp}
+ version := int64(2)
+ message := "updated message"
+ createdUser := "creator"
+ updatedUser := "updator"
+
+ t.Run("Should scan a valid row correctly", func(t *testing.T) {
+ rows := sqlmock.NewRows(columns).AddRow(1, id, title, folderUID, nil, "", "", "", "", 0, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`))
+ mock.ExpectQuery("SELECT *").WillReturnRows(rows)
+ resultRows, err := mockDB.Query("SELECT *")
+ require.NoError(t, err)
+ defer resultRows.Close() // nolint:errcheck
+ resultRows.Next()
+
+ row, err := store.scanRow(resultRows, false)
+ require.NoError(t, err)
+ require.NotNil(t, row)
+ require.Equal(t, "Test Dashboard", row.Dash.Name)
+ require.Equal(t, version, row.RV) // rv should be the dashboard version
+ require.Equal(t, v0alpha1.Unstructured{
+ Object: map[string]interface{}{"key": "value"},
+ }, row.Dash.Spec)
+ require.Equal(t, "default", row.Dash.Namespace)
+ require.Equal(t, &continueToken{orgId: int64(1), id: id}, row.token)
+
+ meta, err := utils.MetaAccessor(row.Dash)
+ require.NoError(t, err)
+ require.Equal(t, id, meta.GetDeprecatedInternalID()) // nolint:staticcheck
+ require.Equal(t, version, meta.GetGeneration()) // generation should be dash version
+ require.Equal(t, k8sTimestamp, meta.GetCreationTimestamp())
+ require.Equal(t, "user:"+createdUser, meta.GetCreatedBy()) // should be prefixed by user:
+ require.Equal(t, "user:"+updatedUser, meta.GetUpdatedBy()) // should be prefixed by user:
+ require.Equal(t, message, meta.GetMessage())
+ require.Equal(t, folderUID, meta.GetFolder())
+ })
+
+ t.Run("File provisioned dashboard should have annotations", func(t *testing.T) {
+ rows := sqlmock.NewRows(columns).AddRow(1, id, title, folderUID, nil, "", "provisioner", pathToFile, "hashing", 100000, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`))
+ mock.ExpectQuery("SELECT *").WillReturnRows(rows)
+ resultRows, err := mockDB.Query("SELECT *")
+ require.NoError(t, err)
+ defer resultRows.Close() // nolint:errcheck
+ resultRows.Next()
+
+ row, err := store.scanRow(resultRows, false)
+ require.NoError(t, err)
+ require.NotNil(t, row)
+
+ meta, err := utils.MetaAccessor(row.Dash)
+ require.NoError(t, err)
+ require.Equal(t, "file:provisioner", meta.GetRepositoryName()) // should be prefixed by file:
+ require.Equal(t, "../"+pathToFile, meta.GetRepositoryPath()) // relative to provisioner
+ require.Equal(t, "hashing", meta.GetRepositoryHash())
+ ts, err := meta.GetRepositoryTimestamp()
+ require.NoError(t, err)
+ require.Equal(t, int64(100000), ts.Unix())
+ })
+
+ t.Run("Plugin provisioned dashboard should have annotations", func(t *testing.T) {
+ rows := sqlmock.NewRows(columns).AddRow(1, id, title, folderUID, nil, "slo", "", "", "", 0, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`))
+ mock.ExpectQuery("SELECT *").WillReturnRows(rows)
+ resultRows, err := mockDB.Query("SELECT *")
+ require.NoError(t, err)
+ defer resultRows.Close() // nolint:errcheck
+ resultRows.Next()
+
+ row, err := store.scanRow(resultRows, false)
+ require.NoError(t, err)
+ require.NotNil(t, row)
+
+ meta, err := utils.MetaAccessor(row.Dash)
+ require.NoError(t, err)
+ require.Equal(t, "plugin", meta.GetRepositoryName())
+ require.Equal(t, "slo", meta.GetRepositoryPath()) // the ID of the plugin
+ require.Equal(t, "", meta.GetRepositoryHash()) // hash is not used on plugins
+ })
+}
diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client.go b/pkg/registry/apis/dashboard/legacysearcher/search_client.go
index bd2e4a896a8..71501c3fdcd 100644
--- a/pkg/registry/apis/dashboard/legacysearcher/search_client.go
+++ b/pkg/registry/apis/dashboard/legacysearcher/search_client.go
@@ -13,10 +13,12 @@ import (
"github.com/grafana/grafana/pkg/apis/dashboard"
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"google.golang.org/grpc"
+ "k8s.io/apimachinery/pkg/selection"
)
type DashboardSearchClient struct {
@@ -28,6 +30,7 @@ func NewDashboardSearchClient(dashboardStore dashboards.Store) *DashboardSearchC
return &DashboardSearchClient{dashboardStore: dashboardStore}
}
+// nolint:gocyclo
func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) {
user, err := identity.GetRequester(ctx)
if err != nil {
@@ -40,9 +43,6 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
req.Query = strings.ReplaceAll(req.Query, "*", "")
}
- // TODO add missing support for the following query params:
- // - folderIds (won't support, must use folderUIDs)
- // - permission
query := &dashboards.FindPersistedDashboardsQuery{
Title: req.Query,
Limit: req.Limit,
@@ -51,6 +51,10 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
IsDeleted: req.IsDeleted,
}
+ if req.Permission == int64(dashboardaccess.PERMISSION_EDIT) {
+ query.Permission = dashboardaccess.PERMISSION_EDIT
+ }
+
var queryType string
if req.Options.Key.Resource == dashboard.DASHBOARD_RESOURCE {
queryType = searchstore.TypeDashboard
@@ -101,6 +105,8 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
}
for _, field := range req.Options.Fields {
+ vals := field.GetValues()
+
switch field.Key {
case resource.SEARCH_FIELD_TAGS:
query.Tags = field.GetValues()
@@ -108,7 +114,6 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
query.DashboardUIDs = field.GetValues()
query.DashboardIds = nil
case resource.SEARCH_FIELD_FOLDER:
- vals := field.GetValues()
folders := make([]string, len(vals))
for i, val := range vals {
@@ -120,25 +125,30 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
}
query.FolderUIDs = folders
+ case resource.SEARCH_FIELD_REPOSITORY_PATH:
+ // only one value is supported in legacy search
+ if len(vals) != 1 {
+ return nil, fmt.Errorf("only one repo path query is supported")
+ }
+ query.ProvisionedPath = vals[0]
+ case resource.SEARCH_FIELD_REPOSITORY_NAME:
+ if field.Operator == string(selection.NotIn) {
+ for _, val := range vals {
+ name, _ := dashboard.GetProvisionedFileNameFromMeta(val)
+ query.ProvisionedReposNotIn = append(query.ProvisionedReposNotIn, name)
+ }
+ continue
+ }
+
+ // only one value is supported in legacy search
+ if len(vals) != 1 {
+ return nil, fmt.Errorf("only one repo name is supported")
+ }
+
+ query.ProvisionedRepo, _ = dashboard.GetProvisionedFileNameFromMeta(vals[0])
}
}
- // TODO need to test this
- // emptyResponse, err := a.dashService.GetSharedDashboardUIDsQuery(ctx, query)
-
- // if err != nil {
- // return nil, err
- // } else if emptyResponse {
- // return nil, nil
- // }
-
- res, err := c.dashboardStore.FindDashboards(ctx, query)
- if err != nil {
- return nil, err
- }
-
- // TODO sort if query.Sort == "" see sortedHits in services/search/service.go
-
searchFields := resource.StandardSearchFields()
list := &resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
@@ -150,6 +160,41 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
},
}
+ // if we are querying for provisioning information, we need to use a different
+ // legacy sql query, since legacy search does not support this
+ if query.ProvisionedRepo != "" || len(query.ProvisionedReposNotIn) > 0 {
+ var dashes []*dashboards.Dashboard
+ if query.ProvisionedRepo == dashboard.PluginIDRepoName {
+ dashes, err = c.dashboardStore.GetDashboardsByPluginID(ctx, &dashboards.GetDashboardsByPluginIDQuery{
+ PluginID: query.ProvisionedPath,
+ OrgID: user.GetOrgID(),
+ })
+ } else if query.ProvisionedRepo != "" {
+ dashes, err = c.dashboardStore.GetProvisionedDashboardsByName(ctx, query.ProvisionedRepo)
+ } else if len(query.ProvisionedReposNotIn) > 0 {
+ dashes, err = c.dashboardStore.GetOrphanedProvisionedDashboards(ctx, query.ProvisionedReposNotIn)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ for _, dashboard := range dashes {
+ list.Results.Rows = append(list.Results.Rows, &resource.ResourceTableRow{
+ Key: getResourceKey(&dashboards.DashboardSearchProjection{
+ UID: dashboard.UID,
+ }, req.Options.Key.Namespace),
+ Cells: [][]byte{[]byte(dashboard.Title), []byte(dashboard.FolderUID), []byte{}},
+ })
+ }
+
+ return list, nil
+ }
+
+ res, err := c.dashboardStore.FindDashboards(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+
hits := formatQueryResult(res)
for _, dashboard := range hits {
@@ -164,6 +209,8 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
})
}
+ list.TotalHits = int64(len(list.Results.Rows))
+
return list, nil
}
diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go
new file mode 100644
index 00000000000..14dc870bbd4
--- /dev/null
+++ b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go
@@ -0,0 +1,264 @@
+package legacysearcher
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/grafana/grafana/pkg/apimachinery/identity"
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ "github.com/grafana/grafana/pkg/apis/dashboard"
+ "github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/user"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/selection"
+)
+
+func TestDashboardSearchClient_Search(t *testing.T) {
+ mockStore := dashboards.NewFakeDashboardStore(t)
+ client := NewDashboardSearchClient(mockStore)
+ ctx := context.Background()
+ user := &user.SignedInUser{OrgID: 2}
+ ctx = identity.WithRequester(ctx, user)
+
+ dashboardKey := &resource.ResourceKey{
+ Name: "uid",
+ Resource: dashboard.DASHBOARD_RESOURCE,
+ }
+
+ t.Run("Should parse results into GRPC", func(t *testing.T) {
+ mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
+ SignedInUser: user, // user from context should be used
+ Type: "dash-db", // should set type based off of key
+ }).Return([]dashboards.DashboardSearchProjection{
+ {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1", Term: "term"},
+ {UID: "uid2", Title: "Test Dashboard2", FolderUID: "folder2"},
+ }, nil).Once()
+
+ req := &resource.ResourceSearchRequest{
+ Options: &resource.ListOptions{
+ Key: dashboardKey,
+ },
+ }
+ resp, err := client.Search(ctx, req)
+ require.NoError(t, err)
+
+ tags, err := json.Marshal([]string{"term"})
+ require.NoError(t, err)
+
+ emptyTags, err := json.Marshal([]string{})
+ require.NoError(t, err)
+
+ require.NotNil(t, resp)
+ searchFields := resource.StandardSearchFields()
+ require.Equal(t, &resource.ResourceSearchResponse{
+ TotalHits: 2,
+ Results: &resource.ResourceTable{
+ Columns: []*resource.ResourceTableColumnDefinition{
+ searchFields.Field(resource.SEARCH_FIELD_TITLE),
+ searchFields.Field(resource.SEARCH_FIELD_FOLDER),
+ searchFields.Field(resource.SEARCH_FIELD_TAGS),
+ },
+ Rows: []*resource.ResourceTableRow{
+ {
+ Key: &resource.ResourceKey{
+ Name: "uid",
+ Group: dashboard.GROUP,
+ Resource: dashboard.DASHBOARD_RESOURCE,
+ },
+ Cells: [][]byte{
+ []byte("Test Dashboard"),
+ []byte("folder1"),
+ tags,
+ },
+ },
+ {
+ Key: &resource.ResourceKey{
+ Name: "uid2",
+ Group: dashboard.GROUP,
+ Resource: dashboard.DASHBOARD_RESOURCE,
+ },
+ Cells: [][]byte{
+ []byte("Test Dashboard2"),
+ []byte("folder2"),
+ emptyTags,
+ },
+ },
+ },
+ },
+ }, resp)
+ mockStore.AssertExpectations(t)
+ })
+
+ t.Run("Query should be set as the title, and * should be removed", func(t *testing.T) {
+ mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
+ Title: "test",
+ SignedInUser: user, // user from context should be used
+ Type: "dash-db", // should set type based off of key
+ }).Return([]dashboards.DashboardSearchProjection{
+ {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
+ }, nil).Once()
+
+ req := &resource.ResourceSearchRequest{
+ Options: &resource.ListOptions{
+ Key: dashboardKey,
+ },
+ Query: "*test*",
+ }
+ resp, err := client.Search(ctx, req)
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ mockStore.AssertExpectations(t)
+ })
+
+ t.Run("Should read labels for the dashboard ids", func(t *testing.T) {
+ mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
+ DashboardIds: []int64{1, 2},
+ SignedInUser: user, // user from context should be used
+ Type: "dash-db", // should set type based off of key
+ }).Return([]dashboards.DashboardSearchProjection{
+ {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
+ }, nil).Once()
+
+ req := &resource.ResourceSearchRequest{
+ Options: &resource.ListOptions{
+ Key: dashboardKey,
+ Labels: []*resource.Requirement{
+ {
+ Key: utils.LabelKeyDeprecatedInternalID,
+ Operator: "in",
+ Values: []string{"1", "2"},
+ },
+ },
+ },
+ }
+ resp, err := client.Search(ctx, req)
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ mockStore.AssertExpectations(t)
+ })
+
+ t.Run("Should modify fields to legacy compatible queries", func(t *testing.T) {
+ mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
+ DashboardUIDs: []string{"uid1", "uid2"},
+ Tags: []string{"tag1", "tag2"},
+ FolderUIDs: []string{"general", "folder1"},
+ SignedInUser: user, // user from context should be used
+ Type: "dash-db", // should set type based off of key
+ }).Return([]dashboards.DashboardSearchProjection{
+ {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
+ }, nil).Once()
+
+ req := &resource.ResourceSearchRequest{
+ Options: &resource.ListOptions{
+ Key: dashboardKey,
+ Fields: []*resource.Requirement{
+ {
+ Key: resource.SEARCH_FIELD_TAGS,
+ Operator: "in",
+ Values: []string{"tag1", "tag2"},
+ },
+ {
+ Key: resource.SEARCH_FIELD_NAME, // name should be used as uid
+ Operator: "in",
+ Values: []string{"uid1", "uid2"},
+ },
+ {
+ Key: resource.SEARCH_FIELD_FOLDER,
+ Operator: "in",
+ Values: []string{"", "folder1"}, // empty folder should be general
+ },
+ },
+ },
+ }
+ resp, err := client.Search(ctx, req)
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ mockStore.AssertExpectations(t)
+ })
+
+ t.Run("Should retrieve dashboards by plugin through a different function", func(t *testing.T) {
+ mockStore.On("GetDashboardsByPluginID", mock.Anything, &dashboards.GetDashboardsByPluginIDQuery{
+ PluginID: "slo",
+ OrgID: 2, // retrieved from the signed in user
+ }).Return([]*dashboards.Dashboard{
+ {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
+ }, nil).Once()
+
+ req := &resource.ResourceSearchRequest{
+ Options: &resource.ListOptions{
+ Key: dashboardKey,
+ Fields: []*resource.Requirement{
+ {
+ Key: resource.SEARCH_FIELD_REPOSITORY_PATH,
+ Operator: "in",
+ Values: []string{"slo"},
+ },
+ {
+ Key: resource.SEARCH_FIELD_REPOSITORY_NAME,
+ Operator: "in",
+ Values: []string{"plugin"},
+ },
+ },
+ },
+ }
+ resp, err := client.Search(ctx, req)
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ mockStore.AssertExpectations(t)
+ })
+
+ t.Run("Should retrieve dashboards by provisioner name through a different function", func(t *testing.T) {
+ mockStore.On("GetProvisionedDashboardsByName", mock.Anything, "test").Return([]*dashboards.Dashboard{
+ {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
+ }, nil).Once()
+
+ req := &resource.ResourceSearchRequest{
+ Options: &resource.ListOptions{
+ Key: dashboardKey,
+ Fields: []*resource.Requirement{
+ {
+ Key: resource.SEARCH_FIELD_REPOSITORY_NAME,
+ Operator: "in",
+ Values: []string{"file:test"}, // file prefix should be removed before going to legacy
+ },
+ },
+ },
+ }
+ resp, err := client.Search(ctx, req)
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ mockStore.AssertExpectations(t)
+ })
+
+ t.Run("Should retrieve orphaned dashboards if provisioner not in is specified", func(t *testing.T) {
+ mockStore.On("GetOrphanedProvisionedDashboards", mock.Anything, []string{"test", "test2"}).Return([]*dashboards.Dashboard{
+ {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
+ }, nil).Once()
+
+ req := &resource.ResourceSearchRequest{
+ Options: &resource.ListOptions{
+ Key: dashboardKey,
+ Fields: []*resource.Requirement{
+ {
+ Key: resource.SEARCH_FIELD_REPOSITORY_NAME,
+ Operator: string(selection.NotIn),
+ Values: []string{"file:test", "file:test2"}, // file prefix should be removed before going to legacy
+ },
+ },
+ },
+ }
+ resp, err := client.Search(ctx, req)
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ mockStore.AssertExpectations(t)
+ })
+}
diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go
index c4ad9532066..9edd79f5f83 100644
--- a/pkg/registry/apis/dashboard/search.go
+++ b/pkg/registry/apis/dashboard/search.go
@@ -3,6 +3,7 @@ package dashboard
import (
"context"
"encoding/json"
+ "fmt"
"net/http"
"net/url"
"slices"
@@ -25,7 +26,10 @@ import (
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/services/dashboards"
dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ foldermodel "github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/util/errhttp"
@@ -33,17 +37,19 @@ import (
// The DTO returns everything the UI needs in a single request
type SearchHandler struct {
- log log.Logger
- client func(context.Context) resource.ResourceIndexClient
- tracer trace.Tracer
+ log log.Logger
+ client func(context.Context) resource.ResourceIndexClient
+ tracer trace.Tracer
+ features featuremgmt.FeatureToggles
}
-func NewSearchHandler(tracer trace.Tracer, cfg *setting.Cfg, legacyDashboardSearcher resource.ResourceIndexClient) *SearchHandler {
+func NewSearchHandler(tracer trace.Tracer, cfg *setting.Cfg, legacyDashboardSearcher resource.ResourceIndexClient, features featuremgmt.FeatureToggles) *SearchHandler {
searchClient := resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, unified.GetResourceClient, legacyDashboardSearcher)
return &SearchHandler{
- client: searchClient,
- log: log.New("grafana-apiserver.dashboards.search"),
- tracer: tracer,
+ client: searchClient,
+ log: log.New("grafana-apiserver.dashboards.search"),
+ tracer: tracer,
+ features: features,
}
}
@@ -252,19 +258,6 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
}
searchRequest.Fields = fields
- // Add the folder constraint. Note this does not do recursive search
- folder := queryParams.Get("folder")
- if folder != "" {
- if folder == rootFolder {
- folder = "" // root folder is empty in the search index
- }
- searchRequest.Options.Fields = []*resource.Requirement{{
- Key: "folder",
- Operator: "=",
- Values: []string{folder},
- }}
- }
-
types := queryParams["type"]
var federate *resource.ResourceKey
switch len(types) {
@@ -329,7 +322,33 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
}
// The names filter
- if names, ok := queryParams["name"]; ok {
+ names := queryParams["name"]
+
+ // Add the folder constraint. Note this does not do recursive search
+ folder := queryParams.Get("folder")
+ if folder == foldermodel.SharedWithMeFolderUID {
+ dashboardUIDs, err := s.getDashboardsUIDsSharedWithUser(ctx, user)
+ if err != nil {
+ errhttp.Write(ctx, err, w)
+ return
+ }
+
+ // hijacks the "name" query param to only search for shared dashboard UIDs
+ if len(dashboardUIDs) > 0 {
+ names = append(names, dashboardUIDs...)
+ }
+ } else if folder != "" {
+ if folder == rootFolder {
+ folder = "" // root folder is empty in the search index
+ }
+ searchRequest.Options.Fields = []*resource.Requirement{{
+ Key: "folder",
+ Operator: "=",
+ Values: []string{folder},
+ }}
+ }
+
+ if len(names) > 0 {
if searchRequest.Options.Fields == nil {
searchRequest.Options.Fields = []*resource.Requirement{}
}
@@ -378,3 +397,108 @@ func asResourceKey(ns string, k string) (*resource.ResourceKey, error) {
return key, nil
}
+
+func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, user identity.Requester) ([]string, error) {
+ if !s.features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering) {
+ return []string{}, nil
+ }
+
+ // gets dashboards that the user was granted read access to
+ permissions := user.GetPermissions()
+ dashboardPermissions := permissions[dashboards.ActionDashboardsRead]
+ dashboardUids := make([]string, 0)
+ sharedDashboards := make([]string, 0)
+
+ for _, dashboardPermission := range dashboardPermissions {
+ if dashboardUid, found := strings.CutPrefix(dashboardPermission, dashboards.ScopeDashboardsPrefix); found {
+ if !slices.Contains(dashboardUids, dashboardUid) {
+ dashboardUids = append(dashboardUids, dashboardUid)
+ }
+ }
+ }
+
+ if len(dashboardUids) == 0 {
+ return sharedDashboards, nil
+ }
+
+ key, err := asResourceKey(user.GetNamespace(), dashboard.DASHBOARD_RESOURCE)
+ if err != nil {
+ return sharedDashboards, err
+ }
+
+ dashboardSearchRequest := &resource.ResourceSearchRequest{
+ Fields: []string{"folder"},
+ Limit: int64(len(dashboardUids)),
+ Options: &resource.ListOptions{
+ Key: key,
+ Fields: []*resource.Requirement{{
+ Key: "name",
+ Operator: "in",
+ Values: dashboardUids,
+ }},
+ },
+ }
+ // get all dashboards user has access to, along with their parent folder uid
+ dashboardResult, err := s.client(ctx).Search(ctx, dashboardSearchRequest)
+ if err != nil {
+ return sharedDashboards, err
+ }
+
+ folderUidIdx := -1
+ for i, col := range dashboardResult.Results.Columns {
+ if col.Name == "folder" {
+ folderUidIdx = i
+ }
+ }
+
+ if folderUidIdx == -1 {
+ return sharedDashboards, fmt.Errorf("Error retrieving folder information")
+ }
+
+ // populate list of unique folder UIDs in the list of dashboards user has read permissions
+ allFolders := make([]string, 0)
+ for _, dash := range dashboardResult.Results.Rows {
+ folderUid := string(dash.Cells[folderUidIdx])
+ if folderUid != "" && !slices.Contains(allFolders, folderUid) {
+ allFolders = append(allFolders, folderUid)
+ }
+ }
+
+ // only folders the user has access to will be returned here
+ folderKey, err := asResourceKey(user.GetNamespace(), folderv0alpha1.RESOURCE)
+ if err != nil {
+ return sharedDashboards, err
+ }
+
+ folderSearchRequest := &resource.ResourceSearchRequest{
+ Fields: []string{"folder"},
+ Limit: int64(len(allFolders)),
+ Options: &resource.ListOptions{
+ Key: folderKey,
+ Fields: []*resource.Requirement{{
+ Key: "name",
+ Operator: "in",
+ Values: allFolders,
+ }},
+ },
+ }
+ foldersResult, err := s.client(ctx).Search(ctx, folderSearchRequest)
+ if err != nil {
+ return sharedDashboards, err
+ }
+
+ foldersWithAccess := make([]string, 0, len(foldersResult.Results.Rows))
+ for _, fold := range foldersResult.Results.Rows {
+ foldersWithAccess = append(foldersWithAccess, fold.Key.Name)
+ }
+
+ // add to sharedDashboards dashboards user has access to, but does NOT have access to it's parent folder
+ for _, dash := range dashboardResult.Results.Rows {
+ dashboardUid := dash.Key.Name
+ folderUid := string(dash.Cells[folderUidIdx])
+ if folderUid != "" && !slices.Contains(foldersWithAccess, folderUid) {
+ sharedDashboards = append(sharedDashboards, dashboardUid)
+ }
+ }
+ return sharedDashboards, nil
+}
diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go
index 91af78295a3..c92b39efdd0 100644
--- a/pkg/registry/apis/dashboard/search_test.go
+++ b/pkg/registry/apis/dashboard/search_test.go
@@ -12,6 +12,8 @@ import (
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
+ "github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
@@ -31,7 +33,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode0},
},
}
- searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
+ searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder()
@@ -59,7 +61,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode1},
},
}
- searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
+ searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder()
@@ -87,7 +89,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode2},
},
}
- searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
+ searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder()
@@ -115,7 +117,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode3},
},
}
- searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
+ searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder()
@@ -143,7 +145,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode4},
},
}
- searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
+ searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder()
@@ -171,7 +173,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5},
},
}
- searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
+ searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder()
@@ -191,17 +193,19 @@ func TestSearchFallback(t *testing.T) {
}
func TestSearchHandler(t *testing.T) {
- // Create a mock client
- mockClient := &MockClient{}
-
- // Initialize the search handler with the mock client
- searchHandler := SearchHandler{
- log: log.New("test", "test"),
- client: func(context.Context) resource.ResourceIndexClient { return mockClient },
- tracer: tracing.NewNoopTracerService(),
- }
-
t.Run("Multiple comma separated fields will be appended to default dashboard search fields", func(t *testing.T) {
+ // Create a mock client
+ mockClient := &MockClient{}
+
+ features := featuremgmt.WithFeatures()
+ // Initialize the search handler with the mock client
+ searchHandler := SearchHandler{
+ log: log.New("test", "test"),
+ client: func(context.Context) resource.ResourceIndexClient { return mockClient },
+ tracer: tracing.NewNoopTracerService(),
+ features: features,
+ }
+
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search?field=field1&field=field2&field=field3", nil)
req.Header.Add("content-type", "application/json")
@@ -219,6 +223,18 @@ func TestSearchHandler(t *testing.T) {
})
t.Run("Single field will be appended to default dashboard search fields", func(t *testing.T) {
+ // Create a mock client
+ mockClient := &MockClient{}
+
+ features := featuremgmt.WithFeatures()
+ // Initialize the search handler with the mock client
+ searchHandler := SearchHandler{
+ log: log.New("test", "test"),
+ client: func(context.Context) resource.ResourceIndexClient { return mockClient },
+ tracer: tracing.NewNoopTracerService(),
+ features: features,
+ }
+
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search?field=field1", nil)
req.Header.Add("content-type", "application/json")
@@ -236,6 +252,18 @@ func TestSearchHandler(t *testing.T) {
})
t.Run("Passing no fields will search using default dashboard fields", func(t *testing.T) {
+ // Create a mock client
+ mockClient := &MockClient{}
+
+ features := featuremgmt.WithFeatures()
+ // Initialize the search handler with the mock client
+ searchHandler := SearchHandler{
+ log: log.New("test", "test"),
+ client: func(context.Context) resource.ResourceIndexClient { return mockClient },
+ tracer: tracing.NewNoopTracerService(),
+ features: features,
+ }
+
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
@@ -253,6 +281,41 @@ func TestSearchHandler(t *testing.T) {
})
t.Run("Sort - default sort by resource then title", func(t *testing.T) {
+ rows := make([]*resource.ResourceTableRow, len(mockResults))
+ for i, r := range mockResults {
+ rows[i] = &resource.ResourceTableRow{
+ Key: &resource.ResourceKey{
+ Name: r.Name,
+ Resource: r.Resource,
+ },
+ Cells: [][]byte{
+ []byte(r.Value),
+ },
+ }
+ }
+
+ mockResponse := &resource.ResourceSearchResponse{
+ Results: &resource.ResourceTable{
+ Columns: []*resource.ResourceTableColumnDefinition{
+ {Name: resource.SEARCH_FIELD_TITLE},
+ },
+ Rows: rows,
+ },
+ }
+ // Create a mock client
+ mockClient := &MockClient{
+ MockResponses: []*resource.ResourceSearchResponse{mockResponse},
+ }
+
+ features := featuremgmt.WithFeatures()
+ // Initialize the search handler with the mock client
+ searchHandler := SearchHandler{
+ log: log.New("test", "test"),
+ client: func(context.Context) resource.ResourceIndexClient { return mockClient },
+ tracer: tracing.NewNoopTracerService(),
+ features: features,
+ }
+
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
@@ -280,6 +343,125 @@ func TestSearchHandler(t *testing.T) {
})
}
+func TestSearchHandlerSharedDashboards(t *testing.T) {
+ t.Run("should bail out if FlagUnifiedStorageSearchPermissionFiltering is not enabled globally", func(t *testing.T) {
+ mockClient := &MockClient{}
+
+ features := featuremgmt.WithFeatures()
+ searchHandler := SearchHandler{
+ log: log.New("test", "test"),
+ client: func(context.Context) resource.ResourceIndexClient { return mockClient },
+ tracer: tracing.NewNoopTracerService(),
+ features: features,
+ }
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil)
+ req.Header.Add("content-type", "application/json")
+ req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
+
+ searchHandler.DoSearch(rr, req)
+
+ assert.Equal(t, mockClient.CallCount, 1)
+ })
+
+ t.Run("should return the dashboards shared with the user", func(t *testing.T) {
+ // dashboardSearchRequest
+ mockResponse1 := &resource.ResourceSearchResponse{
+ Results: &resource.ResourceTable{
+ Columns: []*resource.ResourceTableColumnDefinition{
+ {
+ Name: "folder",
+ },
+ },
+ Rows: []*resource.ResourceTableRow{
+ {
+ Key: &resource.ResourceKey{
+ Name: "dashboardinroot",
+ Resource: "dashboard",
+ },
+ Cells: [][]byte{[]byte("")}, // root folder doesn't have uid
+ },
+ {
+ Key: &resource.ResourceKey{
+ Name: "dashboardinprivatefolder",
+ Resource: "dashboard",
+ },
+ Cells: [][]byte{
+ []byte("privatefolder"), // folder uid
+ },
+ },
+ {
+ Key: &resource.ResourceKey{
+ Name: "dashboardinpublicfolder",
+ Resource: "dashboard",
+ },
+ Cells: [][]byte{
+ []byte("publicfolder"), // folder uid
+ },
+ },
+ },
+ },
+ }
+
+ // folderSearchRequest
+ mockResponse2 := &resource.ResourceSearchResponse{
+ Results: &resource.ResourceTable{
+ Columns: []*resource.ResourceTableColumnDefinition{
+ {
+ Name: "folder",
+ },
+ },
+ Rows: []*resource.ResourceTableRow{
+ {
+ Key: &resource.ResourceKey{
+ Name: "publicfolder",
+ Resource: "folder",
+ },
+ Cells: [][]byte{
+ []byte(""), // root folder uid
+ },
+ },
+ },
+ },
+ }
+
+ mockClient := &MockClient{
+ MockResponses: []*resource.ResourceSearchResponse{mockResponse1, mockResponse2},
+ }
+
+ features := featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering)
+ searchHandler := SearchHandler{
+ log: log.New("test", "test"),
+ client: func(context.Context) resource.ResourceIndexClient { return mockClient },
+ tracer: tracing.NewNoopTracerService(),
+ features: features,
+ }
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil)
+ req.Header.Add("content-type", "application/json")
+ allPermissions := make(map[int64]map[string][]string)
+ permissions := make(map[string][]string)
+ permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"}
+ allPermissions[1] = permissions
+ req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
+
+ searchHandler.DoSearch(rr, req)
+
+ assert.Equal(t, mockClient.CallCount, 3)
+
+ // first call gets all dashboards user has permission for
+ firstCall := mockClient.MockCalls[0]
+ assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"})
+ // second call gets folders associated with the previous dashboards
+ secondCall := mockClient.MockCalls[1]
+ assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"})
+ // lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have
+ // permission to read
+ thirdCall := mockClient.MockCalls[2]
+ assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"})
+ })
+}
+
// MockClient implements the ResourceIndexClient interface for testing
type MockClient struct {
resource.ResourceIndexClient
@@ -287,6 +469,10 @@ type MockClient struct {
// Capture the last SearchRequest for assertions
LastSearchRequest *resource.ResourceSearchRequest
+
+ MockResponses []*resource.ResourceSearchResponse
+ MockCalls []*resource.ResourceSearchRequest
+ CallCount int
}
type MockResult struct {
@@ -320,28 +506,16 @@ var mockResults = []MockResult{
func (m *MockClient) Search(ctx context.Context, in *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) {
m.LastSearchRequest = in
+ m.MockCalls = append(m.MockCalls, in)
- rows := make([]*resource.ResourceTableRow, len(mockResults))
- for i, r := range mockResults {
- rows[i] = &resource.ResourceTableRow{
- Key: &resource.ResourceKey{
- Name: r.Name,
- Resource: r.Resource,
- },
- Cells: [][]byte{
- []byte(r.Value),
- },
- }
+ var response *resource.ResourceSearchResponse
+ if m.CallCount < len(m.MockResponses) {
+ response = m.MockResponses[m.CallCount]
}
- return &resource.ResourceSearchResponse{
- Results: &resource.ResourceTable{
- Columns: []*resource.ResourceTableColumnDefinition{
- {Name: resource.SEARCH_FIELD_TITLE},
- },
- Rows: rows,
- },
- }, nil
+ m.CallCount = m.CallCount + 1
+
+ return response, nil
}
func (m *MockClient) GetStats(ctx context.Context, in *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) {
return nil, nil
diff --git a/pkg/registry/apis/dashboard/sub_dto.go b/pkg/registry/apis/dashboard/sub_dto.go
index e48970a7344..a3c930694ea 100644
--- a/pkg/registry/apis/dashboard/sub_dto.go
+++ b/pkg/registry/apis/dashboard/sub_dto.go
@@ -125,7 +125,7 @@ func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Ob
if err != nil {
return nil, err
}
- if repo != nil && repo.Name == "plugin" {
+ if repo != nil && repo.Name == dashboard.PluginIDRepoName {
dto.PluginID = repo.Path
}
diff --git a/pkg/registry/apis/dashboard/v0alpha1/register.go b/pkg/registry/apis/dashboard/v0alpha1/register.go
index c051bb7595f..b85e5f49329 100644
--- a/pkg/registry/apis/dashboard/v0alpha1/register.go
+++ b/pkg/registry/apis/dashboard/v0alpha1/register.go
@@ -82,7 +82,7 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
features: features,
accessControl: accessControl,
unified: unified,
- search: dashboard.NewSearchHandler(tracing, cfg, legacyDashboardSearcher),
+ search: dashboard.NewSearchHandler(tracing, cfg, legacyDashboardSearcher, features),
legacy: &dashboard.DashboardStorage{
Resource: dashboardv0alpha1.DashboardResourceInfo,
diff --git a/pkg/registry/apis/provisioning/controller/repository.go b/pkg/registry/apis/provisioning/controller/repository.go
index 987db9fb99a..08a5f7a6920 100644
--- a/pkg/registry/apis/provisioning/controller/repository.go
+++ b/pkg/registry/apis/provisioning/controller/repository.go
@@ -24,6 +24,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
type RepoGetter interface {
@@ -57,6 +58,7 @@ type RepositoryController struct {
repoSynced cache.InformerSynced
parsers *resources.ParserFactory
logger logging.Logger
+ secrets secrets.Service
jobs jobs.JobQueue
finalizer *finalizer
@@ -81,6 +83,7 @@ func NewRepositoryController(
parsers *resources.ParserFactory,
tester RepositoryTester,
jobs jobs.JobQueue,
+ secrets secrets.Service,
) (*RepositoryController, error) {
rc := &RepositoryController{
client: provisioningClient,
@@ -99,9 +102,10 @@ func NewRepositoryController(
lister: resourceLister,
client: parsers.Client,
},
- tester: tester,
- jobs: jobs,
- logger: logging.DefaultLogger.With("logger", loggerName),
+ tester: tester,
+ jobs: jobs,
+ logger: logging.DefaultLogger.With("logger", loggerName),
+ secrets: secrets,
}
_, err := repoInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
@@ -229,6 +233,12 @@ func (rc *RepositoryController) process(item *queueItem) error {
return err
}
+ ctx, _, err := identity.WithProvisioningIdentitiy(context.Background(), namespace)
+ if err != nil {
+ return err
+ }
+ logger = logger.WithContext(ctx)
+
healthAge := time.Since(time.UnixMilli(obj.Status.Health.Checked))
syncAge := time.Since(time.UnixMilli(obj.Status.Sync.Finished))
syncInterval := time.Duration(obj.Spec.Sync.IntervalSeconds) * time.Second
@@ -244,12 +254,6 @@ func (rc *RepositoryController) process(item *queueItem) error {
logger.Info("conditions met", "status", obj.Status, "generation", obj.Generation, "deletion_timestamp", obj.DeletionTimestamp, "sync_spec", obj.Spec.Sync)
}
- ctx, _, err := identity.WithProvisioningIdentitiy(context.Background(), namespace)
- if err != nil {
- return err
- }
- logger = logger.WithContext(ctx)
-
repo, err := rc.repoGetter.AsRepository(ctx, obj)
if err != nil {
return fmt.Errorf("unable to create repository from configuration: %w", err)
@@ -342,8 +346,10 @@ func (rc *RepositoryController) process(item *queueItem) error {
}
sync = &provisioning.SyncJobOptions{}
case shouldResync:
- logger.Info("handle repository resync")
- sync = &provisioning.SyncJobOptions{Incremental: true}
+ if obj.Spec.Sync.Enabled {
+ logger.Info("handle repository resync")
+ sync = &provisioning.SyncJobOptions{Incremental: true}
+ }
default:
logger.Info("handle unknown repository situation")
}
diff --git a/pkg/registry/apis/provisioning/jobs/export/folders.go b/pkg/registry/apis/provisioning/jobs/export/folders.go
index 0d2b640178f..fea92b25f23 100644
--- a/pkg/registry/apis/provisioning/jobs/export/folders.go
+++ b/pkg/registry/apis/provisioning/jobs/export/folders.go
@@ -1,39 +1,149 @@
package export
import (
+ "context"
+ "errors"
"fmt"
- "golang.org/x/net/context"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
- "k8s.io/client-go/dynamic"
+ "k8s.io/apimachinery/pkg/runtime/schema"
- apiutils "github.com/grafana/grafana/pkg/apimachinery/utils"
- "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
+ folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/storage/unified/parquet"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
)
-func readFolders(ctx context.Context, client dynamic.ResourceInterface, skip string) (*resources.FolderTree, error) {
- // TODO: handle pagination
- rawList, err := client.List(ctx, metav1.ListOptions{Limit: 10000})
+var _ resource.BatchResourceWriter = (*folderReader)(nil)
+
+type folderReader struct {
+ tree *resources.FolderTree
+ targetRepoName string
+}
+
+// Close implements resource.BatchResourceWriter.
+func (f *folderReader) Close() error {
+ return nil
+}
+
+// CloseWithResults implements resource.BatchResourceWriter.
+func (f *folderReader) CloseWithResults() (*resource.BatchResponse, error) {
+ return &resource.BatchResponse{}, nil
+}
+
+// Write implements resource.BatchResourceWriter.
+func (f *folderReader) Write(ctx context.Context, key *resource.ResourceKey, value []byte) error {
+ item := &unstructured.Unstructured{}
+ err := item.UnmarshalJSON(value)
if err != nil {
- return nil, fmt.Errorf("failed to list folders: %w", err)
- }
- if rawList.GetContinue() != "" {
- return nil, fmt.Errorf("unable to list all folders in one request: %s", rawList.GetContinue())
+ return fmt.Errorf("unmarshal unstructured to JSON: %w", err)
}
- // filter out the folders we already own
- rawFolders := make([]unstructured.Unstructured, 0, len(rawList.Items))
- for _, f := range rawList.Items {
- repoName := f.GetAnnotations()[apiutils.AnnoKeyRepoName]
- if repoName == skip {
- logger.Info("skip as folder is already in repository", "folder", f.GetName())
- continue
+ return f.tree.AddUnstructured(item, f.targetRepoName)
+}
+
+// FIXME: revise logging in this method
+func (r *exportJob) loadFolders(ctx context.Context) error {
+ logger := r.logger
+ r.progress.SetMessage("reading folder tree")
+
+ repoName := r.target.Config().Name
+
+ if r.legacy != nil {
+ r.progress.SetMessage("migrate folder tree from legacy")
+ reader := &folderReader{
+ tree: r.folderTree,
+ targetRepoName: repoName,
+ }
+ _, err := r.legacy.Migrate(ctx, legacy.MigrateOptions{
+ Namespace: r.namespace,
+ Resources: []schema.GroupResource{{
+ Group: folders.GROUP,
+ Resource: folders.RESOURCE,
+ }},
+ Store: parquet.NewBatchResourceWriterClient(reader),
+ })
+ if err != nil {
+ return fmt.Errorf("unable to read folders from legacy storage %w", err)
+ }
+ } else {
+ // TODO: should this be logging or message or both?
+ r.progress.SetMessage("read folder tree from unified storage")
+ client := r.client.Resource(schema.GroupVersionResource{
+ Group: folders.GROUP,
+ Version: folders.VERSION,
+ Resource: folders.RESOURCE,
+ })
+
+ rawList, err := client.List(ctx, metav1.ListOptions{Limit: 10000})
+ if err != nil {
+ return fmt.Errorf("failed to list folders: %w", err)
+ }
+ if rawList.GetContinue() != "" {
+ return fmt.Errorf("unable to list all folders in one request: %s", rawList.GetContinue())
}
- rawFolders = append(rawFolders, f)
+ for _, item := range rawList.Items {
+ err = r.folderTree.AddUnstructured(&item, repoName)
+ if err != nil {
+ r.progress.Record(ctx, jobs.JobResourceResult{
+ Name: item.GetName(),
+ Resource: folders.RESOURCE,
+ Group: folders.GROUP,
+ Error: err,
+ })
+ }
+ }
}
- return resources.NewFolderTreeFromUnstructure(ctx, rawFolders), nil
+ // create folders first is required so that empty folders exist when finished
+ r.progress.SetMessage("write folders")
+
+ err := r.folderTree.Walk(ctx, func(ctx context.Context, folder resources.Folder) error {
+ p := folder.Path + "/"
+ if r.prefix != "" {
+ p = r.prefix + "/" + p
+ }
+ logger := logger.With("path", p)
+
+ result := jobs.JobResourceResult{
+ Name: folder.ID,
+ Resource: folders.RESOURCE,
+ Group: folders.GROUP,
+ Path: p,
+ }
+
+ _, err := r.target.Read(ctx, p, r.ref)
+ if err != nil && !(errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) {
+ result.Error = fmt.Errorf("failed to check if folder exists before writing: %w", err)
+ return result.Error
+ } else if err == nil {
+ logger.Info("folder already exists")
+ result.Action = repository.FileActionIgnored
+ r.progress.Record(ctx, result)
+ return nil
+ }
+
+ result.Action = repository.FileActionCreated
+ msg := fmt.Sprintf("export folder %s", p)
+ // Create with an empty body will make a folder (or .keep file if unsupported)
+ if err := r.target.Create(ctx, p, r.ref, nil, msg); err != nil {
+ result.Error = fmt.Errorf("failed to write folder in repo: %w", err)
+ r.progress.Record(ctx, result)
+ return result.Error
+ }
+
+ r.progress.Record(ctx, result)
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("failed to write folders: %w", err)
+ }
+
+ return nil
}
diff --git a/pkg/registry/apis/provisioning/jobs/export/job.go b/pkg/registry/apis/provisioning/jobs/export/job.go
index 168406578f4..b36236e1690 100644
--- a/pkg/registry/apis/provisioning/jobs/export/job.go
+++ b/pkg/registry/apis/provisioning/jobs/export/job.go
@@ -2,22 +2,11 @@ package export
import (
"context"
- "encoding/json"
- "errors"
- "fmt"
- "time"
-
- apierrors "k8s.io/apimachinery/pkg/api/errors"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
- "k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/pkg/apimachinery/utils"
- folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
- "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
- "github.com/grafana/grafana/pkg/infra/slugify"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
@@ -26,261 +15,69 @@ import (
// ExportJob holds all context for a running job
type exportJob struct {
- logger logging.Logger
- client *resources.DynamicClient // Read from
- target repository.Repository // Write to
+ logger logging.Logger
+ client *resources.DynamicClient // Read from
+ target repository.Repository // Write to
+ legacy legacy.LegacyMigrator
+ namespace string
- progress jobs.ProgressFn
- progressInterval time.Duration
- progressLast time.Time
- foldersTree *resources.FolderTree
+ progress *jobs.JobProgressRecorder
+
+ userInfo map[string]repository.CommitSignature
+ folderTree *resources.FolderTree
prefix string // from options (now clean+safe)
ref string // from options (only git)
keepIdentifier bool
- addAuthorInfo bool
-
- jobStatus *provisioning.JobStatus
- summary map[string]*provisioning.JobResourceSummary
+ withHistory bool
}
func newExportJob(ctx context.Context,
target repository.Repository,
options provisioning.ExportJobOptions,
client *resources.DynamicClient,
- progress jobs.ProgressFn,
+ progress *jobs.JobProgressRecorder,
) *exportJob {
prefix := options.Prefix
if prefix != "" {
prefix = safepath.Clean(prefix)
}
return &exportJob{
- target: target,
- client: client,
- logger: logging.FromContext(ctx),
- progress: progress,
- progressLast: time.Now(),
- progressInterval: time.Second * 10,
-
+ namespace: target.Config().Namespace,
+ target: target,
+ client: client,
+ logger: logging.FromContext(ctx),
+ progress: progress,
prefix: prefix,
ref: options.Branch,
keepIdentifier: options.Identifier,
- addAuthorInfo: options.History,
-
- jobStatus: &provisioning.JobStatus{
- State: provisioning.JobStateWorking,
- },
- summary: make(map[string]*provisioning.JobResourceSummary),
+ withHistory: options.History,
+ folderTree: resources.NewEmptyFolderTree(),
}
}
-// Send progress messages to any listeners
-func (r *exportJob) maybeNotify(ctx context.Context) {
- if time.Since(r.progressLast) > r.progressInterval {
- err := r.progress(ctx, *r.jobStatus)
- if err != nil {
- r.logger.Warn("unable to send progress", "err", err)
- }
- }
-}
-
-// Register summary information for a group/resource
-func (r *exportJob) getSummary(gr schema.GroupResource) *provisioning.JobResourceSummary {
- summary, ok := r.summary[gr.String()]
- if !ok {
- summary = &provisioning.JobResourceSummary{
- Group: gr.Group,
- Resource: gr.Resource,
- }
- r.summary[gr.String()] = summary
- r.jobStatus.Summary = append(r.jobStatus.Summary, summary)
- }
- return summary
-}
-
-func (r *exportJob) loadFolders(ctx context.Context) error {
- logger := r.logger
- targetRepoName := r.target.Config().Name
- status := r.jobStatus
- status.Message = "reading folder tree"
- foldersTree, err := readFolders(ctx, r.client.Resource(schema.GroupVersionResource{
- Group: folders.GROUP,
- Version: folders.VERSION,
- Resource: folders.RESOURCE,
- }), targetRepoName)
-
- summary := r.getSummary(schema.GroupResource{
- Group: folders.GROUP,
- Resource: folders.RESOURCE,
- })
-
- // first create folders
- // TODO! this should not be necessary if writing to a path also makes the parents
- status.Message = "writing folders"
- err = foldersTree.Walk(ctx, func(ctx context.Context, folder resources.Folder) error {
- p := folder.Path + "/"
- if r.prefix != "" {
- p = r.prefix + "/" + p
- }
- logger := logger.With("path", p)
-
- _, err = r.target.Read(ctx, p, r.ref)
- if err != nil && !(errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) {
- logger.Error("failed to check if folder exists before writing", "error", err)
- return fmt.Errorf("failed to check if folder exists before writing: %w", err)
- } else if err == nil {
- logger.Info("folder already exists")
- summary.Noop++
- return nil
- }
-
- // Create with an empty body will make a folder (or .keep file if unsupported)
- if err := r.target.Create(ctx, p, r.ref, nil, "export folder `"+p+"`"); err != nil {
- logger.Error("failed to write a folder in repository", "error", err)
- return fmt.Errorf("failed to write folder in repo: %w", err)
- }
- summary.Create++
- logger.Debug("successfully exported folder")
- return nil
- })
- if err != nil {
- return fmt.Errorf("failed to write folders: %w", err)
- }
- r.foldersTree = foldersTree
- return nil
-}
-
-func (r *exportJob) export(ctx context.Context, kind schema.GroupVersionResource) error {
- r.jobStatus.Message = "Exporting " + kind.Resource + "..."
- r.maybeNotify(ctx)
- client := r.client.Resource(kind)
- summary := r.getSummary(kind.GroupResource())
-
- continueToken := ""
- for {
- list, err := client.List(ctx, metav1.ListOptions{Limit: 100, Continue: continueToken})
- if err != nil {
- return fmt.Errorf("error executing list: %w", err)
- }
-
- for _, item := range list.Items {
- if err = r.add(ctx, summary, &item); err != nil {
- return fmt.Errorf("error adding value: %w", err)
- }
- }
-
- continueToken = list.GetContinue()
- if continueToken == "" {
- break
- }
- }
-
- return nil
-}
-
-func (r *exportJob) add(ctx context.Context, summary *provisioning.JobResourceSummary, obj *unstructured.Unstructured) error {
- if err := ctx.Err(); err != nil {
- return err
- }
- r.maybeNotify(ctx)
-
- item, err := utils.MetaAccessor(obj)
- if err != nil {
- return err
- }
-
- // Message from annotations
- commitMessage := item.GetMessage()
- if commitMessage == "" {
- g := item.GetGeneration()
- if g > 0 {
- commitMessage = fmt.Sprintf("Generation: %d, ResourceVersion: %s", g, item.GetResourceVersion())
- } else {
- commitMessage = "exported from grafana"
- }
- }
-
- name := item.GetName()
- repoName := item.GetRepositoryName()
- if repoName == r.target.Config().GetName() {
- r.logger.Info("skip dashboard since it is already in repository", "dashboard", name)
- return nil
- }
-
- title := item.FindTitle("")
- if title == "" {
- title = name
- }
- folder := item.GetFolder()
-
- // Get the absolute path of the folder
- fid, ok := r.foldersTree.DirPath(folder, "")
- if !ok {
- logger.Error("folder of item was not in tree of repository")
- return fmt.Errorf("folder of item was not in tree of repository")
- }
-
- delete(obj.Object, "metadata")
- if r.keepIdentifier {
- item.SetName(name) // keep the identifier in the metadata
- }
-
- body, err := json.MarshalIndent(obj.Object, "", " ")
- if err != nil {
- return fmt.Errorf("failed to marshal dashboard %s: %w", name, err)
- }
-
- fileName := slugify.Slugify(title) + ".json"
- if fid.Path != "" {
- fileName, err = safepath.Join(fid.Path, fileName)
- if err != nil {
- return fmt.Errorf("error adding file path %s: %w", title, err)
- }
- }
- if r.prefix != "" {
- fileName, err = safepath.Join(r.prefix, fileName)
- if err != nil {
- return fmt.Errorf("error adding path prefix %s: %w", r.prefix, err)
- }
- }
-
- // Add the author in context (if available)
- ctx = r.withAuthorSignature(ctx, item)
-
- // Write the file
- err = r.target.Write(ctx, fileName, r.ref, body, commitMessage)
- if err != nil {
- summary.Error++
- logger.Error("failed to write a file in repository", "error", err)
- if len(summary.Errors) < 20 {
- summary.Errors = append(summary.Errors, fmt.Sprintf("error writing: %s", fileName))
- }
- } else {
- summary.Write++
- }
-
- return nil
-}
-
func (r *exportJob) withAuthorSignature(ctx context.Context, item utils.GrafanaMetaAccessor) context.Context {
- if !r.addAuthorInfo {
+ if r.userInfo == nil {
return ctx
}
+ id := item.GetUpdatedBy()
+ if id == "" {
+ id = item.GetCreatedBy()
+ }
+ if id == "" {
+ id = "grafana"
+ }
- sig := repository.CommitSignature{
- Name: item.GetUpdatedBy(),
- When: item.GetCreationTimestamp().Time,
+ sig := r.userInfo[id] // lookup
+ if sig.Name == "" && sig.Email == "" {
+ sig.Name = id
}
- if sig.Name == "" {
- sig.Name = item.GetCreatedBy()
- }
- if sig.Name == "" {
- return ctx // no user info
- }
- // TODO: convert internal id to name+email
- updated, _ := item.GetUpdatedTimestamp()
- if updated != nil {
- sig.When = *updated
+ t, err := item.GetUpdatedTimestamp()
+ if err == nil && t != nil {
+ sig.When = *t
+ } else {
+ sig.When = item.GetCreationTimestamp().Time
}
+
return repository.WithAuthorSignature(ctx, sig)
}
diff --git a/pkg/registry/apis/provisioning/jobs/export/resources.go b/pkg/registry/apis/provisioning/jobs/export/resources.go
new file mode 100644
index 00000000000..9c182ccfd48
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/export/resources.go
@@ -0,0 +1,228 @@
+package export
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ dashboards "github.com/grafana/grafana/pkg/apis/dashboard"
+ "github.com/grafana/grafana/pkg/infra/slugify"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+ "github.com/grafana/grafana/pkg/storage/unified/parquet"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+)
+
+var _ resource.BatchResourceWriter = (*resourceReader)(nil)
+
+type resourceReader struct {
+ job *exportJob
+}
+
+// Close implements resource.BatchResourceWriter.
+func (f *resourceReader) Close() error {
+ return nil
+}
+
+// CloseWithResults implements resource.BatchResourceWriter.
+func (f *resourceReader) CloseWithResults() (*resource.BatchResponse, error) {
+ return &resource.BatchResponse{}, nil
+}
+
+// Write implements resource.BatchResourceWriter.
+func (f *resourceReader) Write(ctx context.Context, key *resource.ResourceKey, value []byte) error {
+ item := &unstructured.Unstructured{}
+ err := item.UnmarshalJSON(value)
+ if err != nil {
+ // TODO: should we fail the entire execution?
+ return fmt.Errorf("failed to unmarshal unstructured: %w", err)
+ }
+
+ if result := f.job.write(ctx, item); result.Error != nil {
+ f.job.progress.Record(ctx, result)
+ if len(f.job.progress.Errors()) > 20 {
+ return fmt.Errorf("stopping execution due to too many errors")
+ }
+ }
+
+ return nil
+}
+
+func (r *exportJob) loadResources(ctx context.Context) error {
+ kinds := []schema.GroupVersionResource{{
+ Group: dashboards.GROUP,
+ Resource: dashboards.DASHBOARD_RESOURCE,
+ Version: "v1alpha1",
+ }}
+
+ for _, kind := range kinds {
+ r.progress.SetMessage(fmt.Sprintf("exporting %s resource", kind.Resource))
+ if r.legacy != nil {
+ r.progress.SetMessage(fmt.Sprintf("migrate %s resource", kind.Resource))
+ gr := kind.GroupResource()
+ opts := legacy.MigrateOptions{
+ Namespace: r.namespace,
+ WithHistory: r.withHistory,
+ Resources: []schema.GroupResource{gr},
+ Store: parquet.NewBatchResourceWriterClient(&resourceReader{job: r}),
+ OnlyCount: true, // first get the count
+ }
+ stats, err := r.legacy.Migrate(ctx, opts)
+ if err != nil {
+ return fmt.Errorf("unable to count legacy items %w", err)
+ }
+
+ // FIXME: explain why we calculate it in this way
+ if len(stats.Summary) > 0 {
+ count := stats.Summary[0].Count
+ history := stats.Summary[0].History
+ if history > count {
+ count = history // the number of items we will process
+ }
+ r.progress.SetTotal(int(count))
+ }
+
+ opts.OnlyCount = false // this time actually write
+ _, err = r.legacy.Migrate(ctx, opts)
+ if err != nil {
+ return fmt.Errorf("error running legacy migrate %s %w", kind.Resource, err)
+ }
+ }
+
+ r.progress.SetMessage(fmt.Sprintf("reading %s resource", kind.Resource))
+ if err := r.loadResourcesFromAPIServer(ctx, kind); err != nil {
+ return fmt.Errorf("error loading %s %w", kind.Resource, err)
+ }
+ }
+ return nil
+}
+
+func (r *exportJob) loadResourcesFromAPIServer(ctx context.Context, kind schema.GroupVersionResource) error {
+ client := r.client.Resource(kind)
+
+ var continueToken string
+ for {
+ list, err := client.List(ctx, metav1.ListOptions{Limit: 100, Continue: continueToken})
+ if err != nil {
+ return fmt.Errorf("error executing list: %w", err)
+ }
+
+ for _, item := range list.Items {
+ r.progress.Record(ctx, r.write(ctx, &item))
+ if len(r.progress.Errors()) > 20 {
+ return fmt.Errorf("stopping execution due to too many errors")
+ }
+ }
+
+ continueToken = list.GetContinue()
+ if continueToken == "" {
+ break
+ }
+ }
+
+ return nil
+}
+
+func (r *exportJob) write(ctx context.Context, obj *unstructured.Unstructured) jobs.JobResourceResult {
+ gvk := obj.GroupVersionKind()
+ result := jobs.JobResourceResult{
+ Name: obj.GetName(),
+ Resource: gvk.Kind,
+ Group: gvk.Group,
+ Action: repository.FileActionCreated,
+ }
+
+ if err := ctx.Err(); err != nil {
+ result.Error = fmt.Errorf("context error: %w", err)
+ return result
+ }
+
+ meta, err := utils.MetaAccessor(obj)
+ if err != nil {
+ result.Error = fmt.Errorf("extract meta accessor: %w", err)
+ return result
+ }
+
+ // Message from annotations
+ commitMessage := meta.GetMessage()
+ if commitMessage == "" {
+ g := meta.GetGeneration()
+ if g > 0 {
+ commitMessage = fmt.Sprintf("Generation: %d", g)
+ } else {
+ commitMessage = "exported from grafana"
+ }
+ }
+
+ name := meta.GetName()
+ repoName := meta.GetRepositoryName()
+ if repoName == r.target.Config().GetName() {
+ result.Action = repository.FileActionIgnored
+ return result
+ }
+
+ title := meta.FindTitle("")
+ if title == "" {
+ title = name
+ }
+ folder := meta.GetFolder()
+
+ // Add the author in context (if available)
+ ctx = r.withAuthorSignature(ctx, meta)
+
+ // Get the absolute path of the folder
+ fid, ok := r.folderTree.DirPath(folder, "")
+ if !ok {
+ // FIXME: Shouldn't this fail instead?
+ fid = resources.Folder{
+ Path: "__folder_not_found/" + slugify.Slugify(folder),
+ }
+ r.logger.Error("folder of item was not in tree of repository")
+ }
+
+ result.Path = fid.Path
+
+ // Clear the metadata
+ delete(obj.Object, "metadata")
+
+ if r.keepIdentifier {
+ meta.SetName(name) // keep the identifier in the metadata
+ }
+
+ body, err := json.MarshalIndent(obj.Object, "", " ")
+ if err != nil {
+ result.Error = fmt.Errorf("failed to marshal dashboard: %w", err)
+ return result
+ }
+
+ fileName := slugify.Slugify(title) + ".json"
+ if fid.Path != "" {
+ fileName, err = safepath.Join(fid.Path, fileName)
+ if err != nil {
+ result.Error = fmt.Errorf("error adding file path: %w", err)
+ return result
+ }
+ }
+ if r.prefix != "" {
+ fileName, err = safepath.Join(r.prefix, fileName)
+ if err != nil {
+ result.Error = fmt.Errorf("error adding path prefix: %w", err)
+ return result
+ }
+ }
+
+ err = r.target.Write(ctx, fileName, r.ref, body, commitMessage)
+ if err != nil {
+ result.Error = fmt.Errorf("failed to write file: %w", err)
+ }
+
+ return result
+}
diff --git a/pkg/registry/apis/provisioning/jobs/export/users.go b/pkg/registry/apis/provisioning/jobs/export/users.go
new file mode 100644
index 00000000000..5e717f5404b
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/export/users.go
@@ -0,0 +1,57 @@
+package export
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ iam "github.com/grafana/grafana/pkg/apis/iam/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+)
+
+func (r *exportJob) loadUsers(ctx context.Context) error {
+ client := r.client.Resource(schema.GroupVersionResource{
+ Group: iam.GROUP,
+ Version: iam.VERSION,
+ Resource: iam.UserResourceInfo.GroupResource().Resource,
+ })
+
+ rawList, err := client.List(ctx, metav1.ListOptions{Limit: 10000})
+ if err != nil {
+ return fmt.Errorf("failed to list users: %w", err)
+ }
+ if rawList.GetContinue() != "" {
+ return fmt.Errorf("unable to list all users in one request: %s", rawList.GetContinue())
+ }
+
+ var ok bool
+ r.userInfo = make(map[string]repository.CommitSignature)
+ for _, item := range rawList.Items {
+ sig := repository.CommitSignature{}
+ // FIXME: should we improve logging here?
+ sig.Name, ok, err = unstructured.NestedString(item.Object, "spec", "login")
+ if !ok || err != nil {
+ continue
+ }
+ sig.Email, ok, err = unstructured.NestedString(item.Object, "spec", "email")
+ if !ok || err != nil {
+ continue
+ }
+
+ if sig.Name == sig.Email {
+ if sig.Name == "" {
+ sig.Name = item.GetName()
+ } else if strings.Contains(sig.Email, "@") {
+ sig.Email = "" // don't use the same value for name+email
+ }
+ }
+
+ r.userInfo["user:"+item.GetName()] = sig
+ }
+
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/jobs/export/worker.go b/pkg/registry/apis/provisioning/jobs/export/worker.go
index 4478ac0ab0c..4e991136cd0 100644
--- a/pkg/registry/apis/provisioning/jobs/export/worker.go
+++ b/pkg/registry/apis/provisioning/jobs/export/worker.go
@@ -5,23 +5,45 @@ import (
"fmt"
"os"
- "k8s.io/apimachinery/pkg/runtime/schema"
-
- dashboards "github.com/grafana/grafana/pkg/apis/dashboard"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
+ "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
)
type ExportWorker struct {
- clients *resources.ClientFactory
+ // Tempdir for repo clones
clonedir string
+
+ // When exporting from apiservers
+ clients *resources.ClientFactory
+
+ // Check where values are currently saved
+ storageStatus dualwrite.Service
+
+ // Support reading from history
+ legacyMigrator legacy.LegacyMigrator
+
+ secrets secrets.Service
}
-func NewExportWorker(clients *resources.ClientFactory, clonedir string) *ExportWorker {
- return &ExportWorker{clients, clonedir}
+func NewExportWorker(clients *resources.ClientFactory,
+ legacyMigrator legacy.LegacyMigrator,
+ storageStatus dualwrite.Service,
+ secrets secrets.Service,
+ clonedir string,
+) *ExportWorker {
+ return &ExportWorker{
+ clonedir,
+ clients,
+ storageStatus,
+ legacyMigrator,
+ secrets,
+ }
}
func (r *ExportWorker) IsSupported(ctx context.Context, job provisioning.Job) bool {
@@ -29,87 +51,102 @@ func (r *ExportWorker) IsSupported(ctx context.Context, job provisioning.Job) bo
}
// Process will start a job
-func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository, job provisioning.Job, progress jobs.ProgressFn) (*provisioning.JobStatus, error) {
+func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository, job provisioning.Job, progressFn jobs.ProgressFn) (*provisioning.JobStatus, error) {
if repo.Config().Spec.ReadOnly {
return &provisioning.JobStatus{
- State: provisioning.JobStateError,
- Errors: []string{"Exporting to a read only repository is not supported"},
+ State: provisioning.JobStateError,
+ Message: "Exporting to a read only repository is not supported",
}, nil
}
options := job.Spec.Export
if options == nil {
return &provisioning.JobStatus{
- State: provisioning.JobStateError,
- Errors: []string{"Export job missing export settings"},
+ State: provisioning.JobStateError,
+ Message: "Export job missing export settings",
}, nil
}
- var err error
- var buffered *gogit.GoGitRepo
+ progress := jobs.NewJobProgressRecorder(progressFn)
+ var (
+ err error
+ buffered *gogit.GoGitRepo
+ )
+
if repo.Config().Spec.GitHub != nil {
+ progress.SetMessage("clone target")
buffered, err = gogit.Clone(ctx, repo.Config(), gogit.GoGitCloneOptions{
Root: r.clonedir,
SingleCommitBeforePush: !options.History,
- }, os.Stdout)
+ }, r.secrets, os.Stdout)
if err != nil {
return &provisioning.JobStatus{
- State: provisioning.JobStateError,
- Errors: []string{"Unable to clone target", err.Error()},
+ State: provisioning.JobStateError,
+ Message: "Unable to clone target",
+ Errors: []string{err.Error()},
}, nil
}
- // New empty branch
+ // New empty branch (same on main???)
if options.Branch != "" {
+ progress.SetMessage("create empty branch")
_, err := buffered.NewEmptyBranch(ctx, options.Branch)
if err != nil {
return &provisioning.JobStatus{
- State: provisioning.JobStateError,
- Errors: []string{"Unable to create empty branch", err.Error()},
+ State: provisioning.JobStateError,
+ Message: "Unable to create empty branch",
+ Errors: []string{err.Error()},
}, nil
}
}
+
repo = buffered // send all writes to the buffered repo
options.Branch = "" // :( the branch is now baked into the repo
}
dynamicClient, _, err := r.clients.New(repo.Config().Namespace)
if err != nil {
- return nil, fmt.Errorf("namespace mismatch")
+ // TODO: how do we really want to return errors?
+ return nil, fmt.Errorf("error getting client %w", err)
}
worker := newExportJob(ctx, repo, *options, dynamicClient, progress)
- // Load and write all folders
- err = worker.loadFolders(ctx)
- if err != nil {
- return worker.jobStatus, err
- }
-
- kinds := []schema.GroupVersionResource{{
- Group: dashboards.GROUP,
- Version: "v1alpha1",
- Resource: dashboards.DASHBOARD_RESOURCE,
- }}
- for _, kind := range kinds {
- err = worker.export(ctx, kind)
+ if options.History {
+ progress.SetMessage("load users")
+ err = worker.loadUsers(ctx)
if err != nil {
- return worker.jobStatus, err
+ return nil, fmt.Errorf("error loading users %w", err)
}
}
- status := worker.jobStatus
- if buffered != nil && status.State != provisioning.JobStateError {
- status.Message = "pushing changes..."
- worker.maybeNotify(ctx) // force notify?
- err = buffered.Push(ctx, os.Stdout)
- status.Message = ""
+ // Read from legacy if not yet using unified storage
+ if dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, r.storageStatus) {
+ worker.legacy = r.legacyMigrator
}
- // Add summary info to response
- if !status.State.Finished() && err == nil {
- status.State = provisioning.JobStateSuccess
- status.Message = ""
+ // Load and write all folders
+ progress.SetMessage("start folder export")
+ err = worker.loadFolders(ctx)
+ if err != nil {
+ // TODO: handle better
+ return progress.Complete(ctx, err), err
}
- return status, err
+
+ progress.SetMessage("start resource export")
+ err = worker.loadResources(ctx)
+ if err != nil {
+ // TODO: handle better
+ return progress.Complete(ctx, err), err
+ }
+
+ // TODO: handle the errors properly
+ if buffered != nil {
+ progress.SetMessage("push changes")
+ if err := buffered.Push(ctx, os.Stdout); err != nil {
+ return progress.Complete(ctx, err), err
+ }
+ }
+
+ return progress.Complete(ctx, nil), err
}
diff --git a/pkg/registry/apis/provisioning/jobs/progress.go b/pkg/registry/apis/provisioning/jobs/progress.go
index 51b4c1d468c..c6194ea8a6c 100644
--- a/pkg/registry/apis/provisioning/jobs/progress.go
+++ b/pkg/registry/apis/provisioning/jobs/progress.go
@@ -24,8 +24,7 @@ func MaybeNotifyProgress(threshold time.Duration, fn ProgressFn) ProgressFn {
}
}
-// FIXME: ProgressRecorder should be moved to jobs package and initialized in the queue
-
+// FIXME: ProgressRecorder should be initialized in the queue
type JobResourceResult struct {
Name string
Resource string
@@ -36,32 +35,34 @@ type JobResourceResult struct {
}
type JobProgressRecorder struct {
- total int
- ref string
- message string
- results []JobResourceResult
- errors []string
- progressFn ProgressFn
+ total int
+ ref string
+ message string
+ resultCount int
+ errors []string
+ progressFn ProgressFn
+ summaries map[string]*provisioning.JobResourceSummary
}
func NewJobProgressRecorder(progressFn ProgressFn) *JobProgressRecorder {
return &JobProgressRecorder{
progressFn: MaybeNotifyProgress(15*time.Second, progressFn),
+ summaries: make(map[string]*provisioning.JobResourceSummary),
}
}
func (r *JobProgressRecorder) Record(ctx context.Context, result JobResourceResult) {
- if r.results == nil {
- r.results = make([]JobResourceResult, 0)
- }
- r.results = append(r.results, result)
+ r.resultCount++
- logger := logging.FromContext(ctx)
if result.Error != nil {
+ logger := logging.FromContext(ctx)
logger.Error("job resource operation failed", "err", result.Error, "path", result.Path, "resource", result.Resource, "group", result.Group, "action", result.Action, "name", result.Name)
- r.errors = append(r.errors, result.Error.Error())
+ if len(r.errors) < 20 {
+ r.errors = append(r.errors, result.Error.Error())
+ }
}
+ r.updateSummary(result)
r.notify(ctx)
}
@@ -90,71 +91,53 @@ func (r *JobProgressRecorder) Errors() []string {
}
func (r *JobProgressRecorder) summary() []*provisioning.JobResourceSummary {
- if len(r.results) == 0 {
+ if len(r.summaries) == 0 {
return nil
}
- // Group results by resource+group
- groupedResults := make(map[string][]JobResourceResult)
- for _, result := range r.results {
- key := result.Resource + ":" + result.Group
- groupedResults[key] = append(groupedResults[key], result)
- }
-
- summaries := make([]*provisioning.JobResourceSummary, 0)
- for _, results := range groupedResults {
- if len(results) == 0 {
- continue
- }
-
- // Count actions
- actions := make(map[repository.FileAction]int64)
- var errors []string
- for _, result := range results {
- if result.Error != nil {
- errors = append(errors, result.Error.Error())
- } else {
- actions[result.Action]++
- }
- }
-
- // Create summary for this group
-
- // Default to unknown if resource or group is empty
- resource := results[0].Resource
- if resource == "" {
- resource = "unknown"
- }
-
- group := results[0].Group
- if group == "" {
- group = "unknown"
- }
-
- summary := &provisioning.JobResourceSummary{
- Resource: resource,
- Group: group,
- Delete: actions[repository.FileActionDeleted],
- Update: actions[repository.FileActionUpdated],
- Create: actions[repository.FileActionCreated],
- Write: actions[repository.FileActionCreated] + actions[repository.FileActionUpdated],
- Error: int64(len(errors)),
- Noop: actions[repository.FileActionIgnored],
- Errors: errors,
- }
-
+ summaries := make([]*provisioning.JobResourceSummary, 0, len(r.summaries))
+ for _, summary := range r.summaries {
summaries = append(summaries, summary)
}
return summaries
}
+func (r *JobProgressRecorder) updateSummary(result JobResourceResult) {
+ key := result.Resource + ":" + result.Group
+ summary, exists := r.summaries[key]
+ if !exists {
+ summary = &provisioning.JobResourceSummary{
+ Resource: result.Resource,
+ Group: result.Group,
+ }
+ r.summaries[key] = summary
+ }
+
+ if result.Error != nil {
+ summary.Errors = append(summary.Errors, result.Error.Error())
+ summary.Error++
+ } else {
+ switch result.Action {
+ case repository.FileActionDeleted:
+ summary.Delete++
+ case repository.FileActionUpdated:
+ summary.Update++
+ case repository.FileActionCreated:
+ summary.Create++
+ case repository.FileActionIgnored:
+ summary.Noop++
+ }
+ summary.Write = summary.Create + summary.Update
+ }
+}
+
func (r *JobProgressRecorder) progress() float64 {
if r.total == 0 {
return 0
}
- return float64(r.total - len(r.results)/r.total*100)
+ return float64(r.resultCount) / float64(r.total) * 100
}
func (r *JobProgressRecorder) notify(ctx context.Context) {
diff --git a/pkg/registry/apis/provisioning/jobs/sync/legacy.go b/pkg/registry/apis/provisioning/jobs/sync/legacy.go
new file mode 100644
index 00000000000..12ad0e59586
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/sync/legacy.go
@@ -0,0 +1,66 @@
+package sync
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "google.golang.org/grpc/metadata"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ dashboard "github.com/grafana/grafana/pkg/apis/dashboard"
+ folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+)
+
+func (r *SyncWorker) wipeUnifiedAndSetMigratedFlag(ctx context.Context, ns string) error {
+ kinds := []schema.GroupResource{{
+ Group: folders.GROUP,
+ Resource: folders.RESOURCE,
+ }, {
+ Group: dashboard.GROUP,
+ Resource: dashboard.DASHBOARD_RESOURCE,
+ }}
+
+ for _, gr := range kinds {
+ status, _ := r.storageStatus.Status(ctx, gr)
+ if status.ReadUnified {
+ return fmt.Errorf("unexpected state - already using unified storage for: %s", gr)
+ }
+ if status.Migrating > 0 {
+ if time.Since(time.UnixMilli(status.Migrating)) < time.Second*30 {
+ return fmt.Errorf("another migration job is running for: %s", gr)
+ }
+ }
+ settings := resource.BatchSettings{
+ RebuildCollection: true, // wipes everything in the collection
+ Collection: []*resource.ResourceKey{{
+ Namespace: ns,
+ Group: gr.Group,
+ Resource: gr.Resource,
+ }},
+ }
+ ctx = metadata.NewOutgoingContext(ctx, settings.ToMD())
+ stream, err := r.batch.BatchProcess(ctx)
+ if err != nil {
+ return fmt.Errorf("error clearing unified %s / %w", gr, err)
+ }
+ stats, err := stream.CloseAndRecv()
+ if err != nil {
+ return fmt.Errorf("error clearing unified %s / %w", gr, err)
+ }
+ logger := logging.FromContext(ctx)
+ logger.Error("cleared unified stoage", "stats", stats)
+
+ status.Migrated = time.Now().UnixMilli() // but not really... since the sync is starting
+ status.ReadUnified = true
+ status.WriteLegacy = false // keep legacy "clean"
+ _, err = r.storageStatus.Update(ctx, status)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/jobs/sync/worker.go b/pkg/registry/apis/provisioning/jobs/sync/worker.go
index 45839839106..63bf4725548 100644
--- a/pkg/registry/apis/provisioning/jobs/sync/worker.go
+++ b/pkg/registry/apis/provisioning/jobs/sync/worker.go
@@ -28,25 +28,42 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+ "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
)
// SyncWorker synchronizes the external repo with grafana database
// this function updates the status for both the job and the referenced repository
type SyncWorker struct {
- client client.ProvisioningV0alpha1Interface
+ // Used to update the repository status with sync info
+ client client.ProvisioningV0alpha1Interface
+
+ // Lists the values saved in grafana database
+ lister resources.ResourceLister
+
+ // Parses fields saved in remore repository
parsers *resources.ParserFactory
- lister resources.ResourceLister
+
+ // Check if the system is using unified storage
+ storageStatus dualwrite.Service
+
+ // Direct access to unified storage... to wipe any existing values!
+ batch resource.BatchStoreClient
}
func NewSyncWorker(
client client.ProvisioningV0alpha1Interface,
parsers *resources.ParserFactory,
lister resources.ResourceLister,
+ storageStatus dualwrite.Service,
+ batch resource.BatchStoreClient,
) *SyncWorker {
return &SyncWorker{
- client: client,
- parsers: parsers,
- lister: lister,
+ client: client,
+ parsers: parsers,
+ lister: lister,
+ storageStatus: storageStatus,
+ batch: batch,
}
}
@@ -80,6 +97,16 @@ func (r *SyncWorker) Process(ctx context.Context,
return nil, fmt.Errorf("failed to create sync job: %w", err)
}
+ // Check if we are onboarding from legacy storage
+ if dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, r.storageStatus) {
+ if job.Spec.Sync.Incremental {
+ return nil, fmt.Errorf("incremental sync not suppored from legacy state")
+ }
+ if err = r.wipeUnifiedAndSetMigratedFlag(ctx, job.Namespace); err != nil {
+ return nil, err
+ }
+ }
+
// Execute the job
syncError := syncJob.run(ctx, *job.Spec.Sync)
jobStatus := progress.Complete(ctx, syncError)
diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go
index d36a1c084fc..782e7b669e0 100644
--- a/pkg/registry/apis/provisioning/register.go
+++ b/pkg/registry/apis/provisioning/register.go
@@ -31,6 +31,7 @@ import (
clientset "github.com/grafana/grafana/pkg/generated/clientset/versioned"
informers "github.com/grafana/grafana/pkg/generated/informers/externalversions"
listers "github.com/grafana/grafana/pkg/generated/listers/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export"
@@ -45,7 +46,9 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/rendering"
+ grafanasecrets "github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/blob"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
@@ -78,6 +81,10 @@ type APIBuilder struct {
tester *RepositoryTester
resourceLister resources.ResourceLister
repositoryLister listers.RepositoryLister
+ legacyMigrator legacy.LegacyMigrator
+ storageStatus dualwrite.Service
+ unified resource.ResourceClient
+ secrets secrets.Service
}
// NewAPIBuilder creates an API builder.
@@ -89,11 +96,14 @@ func NewAPIBuilder(
webhookSecretKey string,
features featuremgmt.FeatureToggles,
render rendering.Service,
- index resource.RepositoryIndexClient,
+ unified resource.ResourceClient,
blobstore blob.PublicBlobStore,
clonedir string, // where repo clones are managed
configProvider apiserver.RestConfigProvider,
ghFactory github.ClientFactory,
+ legacyMigrator legacy.LegacyMigrator,
+ storageStatus dualwrite.Service,
+ secrets secrets.Service,
) *APIBuilder {
clientFactory := resources.NewFactory(configProvider)
return &APIBuilder{
@@ -108,8 +118,12 @@ func NewAPIBuilder(
},
render: render,
clonedir: clonedir,
- resourceLister: resources.NewResourceLister(index),
+ resourceLister: resources.NewResourceLister(unified),
blobstore: blobstore,
+ legacyMigrator: legacyMigrator,
+ storageStatus: storageStatus,
+ unified: unified,
+ secrets: secrets,
}
}
@@ -125,6 +139,10 @@ func RegisterAPIService(
client resource.ResourceClient, // implements resource.RepositoryClient
configProvider apiserver.RestConfigProvider,
ghFactory github.ClientFactory,
+ legacyMigrator legacy.LegacyMigrator,
+ storageStatus dualwrite.Service,
+ // FIXME: use multi-tenant service when one exists. In this state, we can't make this a multi-tenant service!
+ secretssvc grafanasecrets.Service,
) (*APIBuilder, error) {
if !features.IsEnabledGlobally(featuremgmt.FlagProvisioning) &&
!features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
@@ -148,7 +166,9 @@ func RegisterAPIService(
builder := NewAPIBuilder(folderResolver, urlProvider, cfg.SecretKey, features,
render, client, store,
filepath.Join(cfg.DataPath, "clone"), // where repositories are cloned (temporarialy for now)
- configProvider, ghFactory)
+ configProvider, ghFactory,
+ legacyMigrator, storageStatus,
+ secrets.NewSingleTenant(secretssvc))
apiregistration.RegisterAPI(builder)
return builder, nil
}
@@ -312,10 +332,7 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
gvr.Resource,
r.GetName(),
)
- secretsSvc := secrets.NewService(b.webhookSecretKey)
- return repository.NewGitHub(ctx, r, b.ghFactory, secretsSvc, webhookURL), nil
- case provisioning.S3RepositoryType:
- return repository.NewS3(r), nil
+ return repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, webhookURL)
default:
return nil, errors.New("unknown repository type")
}
@@ -367,6 +384,10 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
}
}
+ if err := b.encryptSecrets(ctx, r); err != nil {
+ return fmt.Errorf("failed to encrypt secrets: %w", err)
+ }
+
return nil
}
@@ -454,11 +475,19 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
}
b.repositoryLister = repoInformer.Lister()
- b.jobs.Register(export.NewExportWorker(b.client, b.clonedir))
+ b.jobs.Register(export.NewExportWorker(
+ b.client,
+ b.legacyMigrator,
+ b.storageStatus,
+ b.secrets,
+ b.clonedir,
+ ))
b.jobs.Register(sync.NewSyncWorker(
c.ProvisioningV0alpha1(),
b.parsers,
b.resourceLister,
+ b.storageStatus,
+ b.unified,
))
renderer := pullrequest.NewRenderer(b.render, b.blobstore)
@@ -476,6 +505,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
b.parsers,
&repository.Tester{},
b.jobs,
+ b.secrets,
)
if err != nil {
return err
@@ -762,3 +792,25 @@ spec:
return oas, nil
}
+
+func (b *APIBuilder) encryptSecrets(ctx context.Context, repo *provisioning.Repository) error {
+ var err error
+ if repo.Spec.GitHub != nil &&
+ repo.Spec.GitHub.Token != "" {
+ repo.Spec.GitHub.EncryptedToken, err = b.secrets.Encrypt(ctx, []byte(repo.Spec.GitHub.Token))
+ if err != nil {
+ return err
+ }
+ repo.Spec.GitHub.Token = ""
+ }
+
+ if repo.Status.Webhook != nil &&
+ repo.Status.Webhook.Secret != "" {
+ repo.Status.Webhook.EncryptedSecret, err = b.secrets.Encrypt(ctx, []byte(repo.Status.Webhook.Secret))
+ if err != nil {
+ return err
+ }
+ repo.Status.Webhook.Secret = ""
+ }
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/repository/github.go b/pkg/registry/apis/provisioning/repository/github.go
index 850e125e276..3b777aa46ed 100644
--- a/pkg/registry/apis/provisioning/repository/github.go
+++ b/pkg/registry/apis/provisioning/repository/github.go
@@ -17,22 +17,20 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
+ "github.com/google/uuid"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
var subscribedEvents = []string{"push", "pull_request"}
-type SecretsService interface {
- Encrypt(ctx context.Context, data string) (string, error)
-}
-
// Make sure all public functions of this struct call the (*githubRepository).logger function, to ensure the GH repo details are included.
type githubRepository struct {
config *provisioning.Repository
gh pgh.Client // assumes github.com base URL
- secrets SecretsService
+ secrets secrets.Service
webhookURL string
owner string
@@ -45,18 +43,22 @@ func NewGitHub(
ctx context.Context,
config *provisioning.Repository,
factory pgh.ClientFactory,
- secrets SecretsService,
+ secrets secrets.Service,
webhookURL string,
-) *githubRepository {
+) (*githubRepository, error) {
owner, repo, _ := parseOwnerRepo(config.Spec.GitHub.URL)
+ decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken)
+ if err != nil {
+ return nil, err
+ }
return &githubRepository{
config: config,
- gh: factory.New(ctx, config.Spec.GitHub.Token), // TODO -- base from URL
+ gh: factory.New(ctx, string(decrypted)), // TODO -- base from URL
secrets: secrets,
webhookURL: webhookURL,
owner: owner,
repo: repo,
- }
+ }, nil
}
func (r *githubRepository) Config() *provisioning.Repository {
@@ -86,7 +88,8 @@ func (r *githubRepository) Validate() (list field.ErrorList) {
if !isValidGitBranchName(gh.Branch) {
list = append(list, field.Invalid(field.NewPath("spec", "github", "branch"), gh.Branch, "invalid branch name"))
}
- if gh.Token == "" {
+ // TODO: Use two fields for token
+ if gh.Token == "" && len(gh.EncryptedToken) == 0 {
list = append(list, field.Required(field.NewPath("spec", "github", "token"), "a github access token is required"))
}
@@ -522,7 +525,12 @@ func (r *githubRepository) Webhook(ctx context.Context, req *http.Request) (*pro
return nil, fmt.Errorf("unexpected webhook request")
}
- payload, err := github.ValidatePayload(req, []byte(r.config.Status.Webhook.Secret))
+ secret, err := r.secrets.Decrypt(ctx, r.config.Status.Webhook.EncryptedSecret)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decrypt secret: %w", err)
+ }
+
+ payload, err := github.ValidatePayload(req, secret)
if err != nil {
return nil, apierrors.NewUnauthorized("invalid signature")
}
@@ -741,14 +749,14 @@ func (r *githubRepository) CommentPullRequestFile(ctx context.Context, prNumber
}
func (r *githubRepository) createWebhook(ctx context.Context) (pgh.WebhookConfig, error) {
- secret, err := r.secrets.Encrypt(ctx, r.config.Spec.GitHub.Token)
+ secret, err := uuid.NewRandom()
if err != nil {
- return pgh.WebhookConfig{}, fmt.Errorf("encrypt webhook secret: %w", err)
+ return pgh.WebhookConfig{}, fmt.Errorf("could not generate secret: %w", err)
}
cfg := pgh.WebhookConfig{
URL: r.webhookURL,
- Secret: secret,
+ Secret: secret.String(),
ContentType: "json",
Events: subscribedEvents,
Active: true,
@@ -759,6 +767,9 @@ func (r *githubRepository) createWebhook(ctx context.Context) (pgh.WebhookConfig
return pgh.WebhookConfig{}, err
}
+ // HACK: GitHub does not return the secret, so we need to update it manually
+ hook.Secret = cfg.Secret
+
logging.FromContext(ctx).Info("webhook created", "url", cfg.URL, "id", hook.ID)
return hook, nil
}
@@ -786,19 +797,10 @@ func (r *githubRepository) updateWebhook(ctx context.Context) (pgh.WebhookConfig
return pgh.WebhookConfig{}, false, fmt.Errorf("get webhook: %w", err)
}
+ hook.Secret = r.config.Status.Webhook.Secret // we always random gen this, so don't use it for mustUpdate below.
+
var mustUpdate bool
- secret, err := r.secrets.Encrypt(ctx, r.config.Spec.GitHub.Token)
- if err != nil {
- return pgh.WebhookConfig{}, false, fmt.Errorf("encrypt webhook secret: %w", err)
- }
-
- // Compare with status secret as we cannot get the screen from the webhook
- if secret != r.config.Status.Webhook.Secret {
- mustUpdate = true
- hook.Secret = r.config.Status.Webhook.Secret
- }
-
if hook.URL != r.config.Status.Webhook.URL {
mustUpdate = true
hook.URL = r.webhookURL
@@ -813,13 +815,17 @@ func (r *githubRepository) updateWebhook(ctx context.Context) (pgh.WebhookConfig
return hook, false, nil
}
+ // Something has changed in the webhook. Let's rotate the secret as well, so as to ensure we end up with a 100% correct webhook.
+ secret, err := uuid.NewRandom()
+ if err != nil {
+ return pgh.WebhookConfig{}, false, fmt.Errorf("could not generate secret: %w", err)
+ }
+ hook.Secret = secret.String()
+
if err := r.gh.EditWebhook(ctx, r.owner, r.repo, hook); err != nil {
return pgh.WebhookConfig{}, false, fmt.Errorf("edit webhook: %w", err)
}
- // HACK: GitHub does not return the secret, so we need to update it manually
- hook.Secret = secret
-
return hook, true, nil
}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/wrapper.go b/pkg/registry/apis/provisioning/repository/go-git/wrapper.go
index d569fe39ffc..9c541be6fbe 100644
--- a/pkg/registry/apis/provisioning/repository/go-git/wrapper.go
+++ b/pkg/registry/apis/provisioning/repository/go-git/wrapper.go
@@ -22,11 +22,10 @@ import (
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
-var (
- _ repository.Repository = (*GoGitRepo)(nil)
-)
+var _ repository.Repository = (*GoGitRepo)(nil)
type GoGitCloneOptions struct {
Root string // tempdir (when empty, memory??)
@@ -36,8 +35,9 @@ type GoGitCloneOptions struct {
}
type GoGitRepo struct {
- config *provisioning.Repository
- opts GoGitCloneOptions
+ config *provisioning.Repository
+ opts GoGitCloneOptions
+ decryptedPassword string
repo *git.Repository
tree *git.Worktree
@@ -50,6 +50,7 @@ func Clone(
ctx context.Context,
config *provisioning.Repository,
opts GoGitCloneOptions,
+ secrets secrets.Service,
progress io.Writer, // os.Stdout
) (*GoGitRepo, error) {
gitcfg := config.Spec.GitHub
@@ -59,7 +60,13 @@ func Clone(
if opts.Root == "" {
return nil, fmt.Errorf("missing root config")
}
- err := os.MkdirAll(opts.Root, 0700)
+
+ decrypted, err := secrets.Decrypt(ctx, []byte(gitcfg.EncryptedToken))
+ if err != nil {
+ return nil, fmt.Errorf("error decrypting token: %w", err)
+ }
+
+ err = os.MkdirAll(opts.Root, 0700)
if err != nil {
return nil, err
}
@@ -68,8 +75,7 @@ func Clone(
return nil, err
}
- url := fmt.Sprintf("/%s.git", gitcfg.URL)
-
+ url := fmt.Sprintf("%s.git", gitcfg.URL)
repo, err := git.PlainOpen(dir)
if err != nil {
if !errors.Is(err, git.ErrRepositoryNotExists) {
@@ -78,8 +84,8 @@ func Clone(
repo, err = git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{
Auth: &githttp.BasicAuth{
- Username: "grafana", // this can be anything except an empty string for PAT
- Password: gitcfg.Token, // TODO... will need to get from a service!
+ Username: "grafana", // this can be anything except an empty string for PAT
+ Password: string(decrypted), // TODO... will need to get from a service!
},
URL: url,
ReferenceName: plumbing.ReferenceName(gitcfg.Branch),
@@ -117,11 +123,12 @@ func Clone(
}
return &GoGitRepo{
- config: config,
- opts: opts,
- tree: worktree,
- repo: repo,
- dir: dir,
+ config: config,
+ opts: opts,
+ tree: worktree,
+ decryptedPassword: string(decrypted),
+ repo: repo,
+ dir: dir,
}, nil
}
@@ -174,7 +181,7 @@ func (g *GoGitRepo) Push(ctx context.Context, progress io.Writer) error {
Progress: progress,
Auth: &githttp.BasicAuth{ // reuse logic from clone?
Username: "grafana",
- Password: g.config.Spec.GitHub.Token,
+ Password: g.decryptedPassword,
},
})
}
@@ -204,7 +211,6 @@ func (g *GoGitRepo) ReadTree(ctx context.Context, ref string) ([]repository.File
entries = append(entries, entry)
return err
})
-
if err != nil {
return nil, err
}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go b/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go
index 82ee7bbae50..e6d6fc05e66 100644
--- a/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go
+++ b/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go
@@ -45,6 +45,7 @@ func TestGoGitWrapper(t *testing.T) {
// one commit (not 11)
SingleCommitBeforePush: true,
},
+ nil, // TODO: add a mock
os.Stdout)
require.NoError(t, err)
diff --git a/pkg/registry/apis/provisioning/repository/s3.go b/pkg/registry/apis/provisioning/repository/s3.go
deleted file mode 100644
index 7726c5b2b28..00000000000
--- a/pkg/registry/apis/provisioning/repository/s3.go
+++ /dev/null
@@ -1,121 +0,0 @@
-package repository
-
-import (
- "context"
- "net/http"
-
- "k8s.io/apimachinery/pkg/api/errors"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/util/validation/field"
-
- provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
-)
-
-type s3Repository struct {
- config *provisioning.Repository
-}
-
-var _ Repository = (*s3Repository)(nil)
-
-func NewS3(config *provisioning.Repository) *s3Repository {
- return &s3Repository{config}
-}
-
-func (r *s3Repository) Config() *provisioning.Repository {
- return r.config
-}
-
-// Validate implements provisioning.Repository.
-func (r *s3Repository) Validate() (list field.ErrorList) {
- s3 := r.Config().Spec.S3
- if s3 == nil {
- list = append(list, field.Required(field.NewPath("spec", "s3"), "an s3 config is required"))
- return
- }
- if s3.Region == "" {
- list = append(list, field.Required(field.NewPath("spec", "s3", "region"), "an s3 region is required"))
- }
- if s3.Bucket == "" {
- list = append(list, field.Required(field.NewPath("spec", "s3", "bucket"), "an s3 bucket name is required"))
- }
- return
-}
-
-// Test implements provisioning.Repository.
-func (r *s3Repository) Test(ctx context.Context) (*provisioning.TestResults, error) {
- return nil, &errors.StatusError{
- ErrStatus: metav1.Status{
- Message: "test is not yet implemented",
- Code: http.StatusNotImplemented,
- },
- }
-}
-
-// ReadResource implements provisioning.Repository.
-func (r *s3Repository) Read(ctx context.Context, path string, ref string) (*FileInfo, error) {
- return nil, &errors.StatusError{
- ErrStatus: metav1.Status{
- Message: "read resource is not yet implemented",
- Code: http.StatusNotImplemented,
- },
- }
-}
-
-func (r *s3Repository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
- return nil, &errors.StatusError{
- ErrStatus: metav1.Status{
- Message: "read file tree resource is not yet implemented",
- Code: http.StatusNotImplemented,
- },
- }
-}
-
-func (r *s3Repository) Create(ctx context.Context, path string, ref string, data []byte, comment string) error {
- return &errors.StatusError{
- ErrStatus: metav1.Status{
- Message: "write file is not yet implemented",
- Code: http.StatusNotImplemented,
- },
- }
-}
-
-func (r *s3Repository) Update(ctx context.Context, path string, ref string, data []byte, comment string) error {
- return &errors.StatusError{
- ErrStatus: metav1.Status{
- Message: "write file is not yet implemented",
- Code: http.StatusNotImplemented,
- },
- }
-}
-
-func (r *s3Repository) Delete(ctx context.Context, path string, ref string, comment string) error {
- return &errors.StatusError{
- ErrStatus: metav1.Status{
- Message: "delete file not yet implemented",
- Code: http.StatusNotImplemented,
- },
- }
-}
-
-func (r *s3Repository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
- return writeWithReadThenCreateOrUpdate(ctx, r, path, ref, data, message)
-}
-
-func (r *s3Repository) History(ctx context.Context, path string, ref string) ([]provisioning.HistoryItem, error) {
- return nil, &errors.StatusError{
- ErrStatus: metav1.Status{
- Message: "history is not yet implemented",
- Code: http.StatusNotImplemented,
- },
- }
-}
-
-// Webhook implements Repository.
-func (r *s3Repository) Webhook(ctx context.Context, req *http.Request) (*provisioning.WebhookResponse, error) {
- return nil, &errors.StatusError{
- ErrStatus: metav1.Status{
- Code: http.StatusNotImplemented,
- Message: "webhook not implemented",
- },
- }
-}
diff --git a/pkg/registry/apis/provisioning/repository/test.go b/pkg/registry/apis/provisioning/repository/test.go
index 7e81129a1ff..3f5c14c7d0f 100644
--- a/pkg/registry/apis/provisioning/repository/test.go
+++ b/pkg/registry/apis/provisioning/repository/test.go
@@ -70,9 +70,5 @@ func ValidateRepository(repo Repository) field.ErrorList {
cfg.Spec.GitHub, "Github config only valid when type is github"))
}
- if cfg.Spec.Type != provisioning.S3RepositoryType && cfg.Spec.S3 != nil {
- list = append(list, field.Invalid(field.NewPath("spec", "s3"),
- cfg.Spec.GitHub, "S3 config only valid when type is s3"))
- }
return list
}
diff --git a/pkg/registry/apis/provisioning/resources/tree.go b/pkg/registry/apis/provisioning/resources/tree.go
index 3525b37b02d..b76c0886c77 100644
--- a/pkg/registry/apis/provisioning/resources/tree.go
+++ b/pkg/registry/apis/provisioning/resources/tree.go
@@ -2,14 +2,16 @@ package resources
import (
"context"
+ "fmt"
"path"
"sort"
"strings"
- apiutils "github.com/grafana/grafana/pkg/apimachinery/utils"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
- "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// FolderTree contains the entire set of folders (at a given snapshot in time) of the Grafana instance.
@@ -97,33 +99,21 @@ func NewEmptyFolderTree() *FolderTree {
}
}
-func NewFolderTreeFromUnstructure(ctx context.Context, rawFolders []unstructured.Unstructured) *FolderTree {
- tree := make(map[string]string, len(rawFolders))
- folders := make(map[string]Folder, len(rawFolders))
-
- for _, rf := range rawFolders {
- name := rf.GetName()
- // TODO: Can I use MetaAccessor here?
- parent := rf.GetAnnotations()[apiutils.AnnoKeyFolder]
- tree[name] = parent
-
- id := Folder{
- Title: name,
- ID: name,
- // TODO: should not this be be the annotation itself?
- Path: "", // We'll set this later in the DirPath function :)
- }
- if title, ok, _ := unstructured.NestedString(rf.Object, "spec", "title"); ok {
- // If the title doesn't exist (it should), we'll just use the K8s name.
- id.Title = title
- }
- folders[name] = id
+func (t *FolderTree) AddUnstructured(item *unstructured.Unstructured, skipRepo string) error {
+ meta, err := utils.MetaAccessor(item)
+ if err != nil {
+ return fmt.Errorf("extract meta accessor: %w", err)
}
-
- return &FolderTree{
- tree: tree,
- folders: folders,
+ if meta.GetRepositoryName() == skipRepo {
+ return nil // skip it... already in tree?
}
+ folder := Folder{
+ Title: meta.FindTitle(item.GetName()),
+ ID: item.GetName(),
+ }
+ t.tree[folder.ID] = meta.GetFolder()
+ t.folders[folder.ID] = folder
+ return nil
}
func NewFolderTreeFromResourceList(resources *provisioning.ResourceList) *FolderTree {
diff --git a/pkg/registry/apis/provisioning/routes.go b/pkg/registry/apis/provisioning/routes.go
index 326d68558e3..b9a0ddc77c6 100644
--- a/pkg/registry/apis/provisioning/routes.go
+++ b/pkg/registry/apis/provisioning/routes.go
@@ -12,6 +12,7 @@ import (
authlib "github.com/grafana/authlib/types"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/util/errhttp"
)
@@ -146,7 +147,8 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) {
}
settings := provisioning.RepositoryViewList{
- Items: make([]provisioning.RepositoryView, len(all)),
+ Items: make([]provisioning.RepositoryView, len(all)),
+ LegacyStorage: dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, b.storageStatus),
}
for i, val := range all {
settings.Items[i] = provisioning.RepositoryView{
diff --git a/pkg/registry/apis/provisioning/secrets/secret.go b/pkg/registry/apis/provisioning/secrets/secret.go
index 3caa141f04a..b7699ab057e 100644
--- a/pkg/registry/apis/provisioning/secrets/secret.go
+++ b/pkg/registry/apis/provisioning/secrets/secret.go
@@ -2,25 +2,34 @@ package secrets
import (
"context"
- "crypto/hmac"
- "crypto/sha256"
- "encoding/hex"
+
+ "github.com/grafana/grafana/pkg/services/secrets"
)
+// A secrets encryption service. It only operates on values, no names or similar.
+// It is likely we will need to change this when the multi-tenant service comes around.
+//
// FIXME: this is a temporary service/package until we can make use of
// the new secrets service in app platform
-type Service struct {
- encryptionKey []byte
+type Service interface {
+ Encrypt(ctx context.Context, data []byte) ([]byte, error)
+ Decrypt(ctx context.Context, data []byte) ([]byte, error)
}
-func NewService(encryptionKey string) *Service {
- return &Service{encryptionKey: []byte(encryptionKey)}
+var _ Service = (*singleTenant)(nil)
+
+type singleTenant struct {
+ inner secrets.Service
}
-func (s *Service) Encrypt(ctx context.Context, data string) (string, error) {
- h := hmac.New(sha256.New, s.encryptionKey)
- h.Write([]byte(data))
- hashed := h.Sum(nil)
+func NewSingleTenant(svc secrets.Service) *singleTenant {
+ return &singleTenant{svc}
+}
- return hex.EncodeToString(hashed), nil
+func (s *singleTenant) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
+ return s.inner.Encrypt(ctx, data, secrets.WithoutScope())
+}
+
+func (s *singleTenant) Decrypt(ctx context.Context, data []byte) ([]byte, error) {
+ return s.inner.Decrypt(ctx, data)
}
diff --git a/pkg/server/wire.go b/pkg/server/wire.go
index d7ea2b2b577..c3c94f816fb 100644
--- a/pkg/server/wire.go
+++ b/pkg/server/wire.go
@@ -36,6 +36,7 @@ import (
"github.com/grafana/grafana/pkg/middleware/csrf"
"github.com/grafana/grafana/pkg/middleware/loggermw"
apiregistry "github.com/grafana/grafana/pkg/registry/apis"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
appregistry "github.com/grafana/grafana/pkg/registry/apps"
"github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -205,6 +206,7 @@ var wireBasicSet = wire.NewSet(
uss.ProvideService,
wire.Bind(new(usagestats.Service), new(*uss.UsageStats)),
validator.ProvideService,
+ legacy.ProvideLegacyMigrator,
pluginsintegration.WireSet,
pluginDashboards.ProvideFileStoreManager,
wire.Bind(new(pluginDashboards.FileStore), new(*pluginDashboards.FileStoreManager)),
diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go
index 94f267c5544..7f4d8cc7843 100644
--- a/pkg/services/apiserver/builder/helper.go
+++ b/pkg/services/apiserver/builder/helper.go
@@ -289,7 +289,7 @@ func InstallAPIs(
if storageOpts.StorageType != options.StorageTypeLegacy {
dualWrite = func(gr schema.GroupResource, legacy grafanarest.LegacyStorage, storage grafanarest.Storage) (grafanarest.Storage, error) {
// Dashboards + Folders may be managed (depends on feature toggles and database state)
- if dualWriteService.ShouldManage(gr) {
+ if dualWriteService != nil && dualWriteService.ShouldManage(gr) {
return dualWriteService.NewStorage(gr, legacy, storage) // eventually this can replace this whole function
}
diff --git a/pkg/services/auth/idimpl/service.go b/pkg/services/auth/idimpl/service.go
index a4b89204d7b..5dec411fdee 100644
--- a/pkg/services/auth/idimpl/service.go
+++ b/pkg/services/auth/idimpl/service.go
@@ -63,7 +63,7 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri
s.metrics.tokenSigningDurationHistogram.Observe(time.Since(t).Seconds())
}(time.Now())
- cacheKey := prefixCacheKey(id.GetCacheKey())
+ cacheKey := getCacheKey(id)
type resultType struct {
token string
@@ -140,7 +140,7 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri
}
func (s *Service) RemoveIDToken(ctx context.Context, id identity.Requester) error {
- return s.cache.Delete(ctx, prefixCacheKey(id.GetCacheKey()))
+ return s.cache.Delete(ctx, getCacheKey(id))
}
func (s *Service) hook(ctx context.Context, identity *authn.Identity, _ *authn.Request) error {
@@ -181,8 +181,8 @@ func getAudience(orgID int64) jwt.Audience {
return jwt.Audience{fmt.Sprintf("org:%d", orgID)}
}
-func prefixCacheKey(key string) string {
- return fmt.Sprintf("%s-%s", cachePrefix, key)
+func getCacheKey(ident identity.Requester) string {
+ return cachePrefix + ident.GetCacheKey() + string(ident.GetOrgRole())
}
func shouldLogErr(err error) bool {
diff --git a/pkg/services/auth/idimpl/service_test.go b/pkg/services/auth/idimpl/service_test.go
index 0f3814968e3..bb7ee510e55 100644
--- a/pkg/services/auth/idimpl/service_test.go
+++ b/pkg/services/auth/idimpl/service_test.go
@@ -16,6 +16,7 @@ import (
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/grafana/grafana/pkg/services/login"
+ "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
)
@@ -101,4 +102,34 @@ func TestService_SignIdentity(t *testing.T) {
assert.Equal(t, claims.TypeUser, gotClaims.Rest.Type)
assert.Equal(t, "edpu3nnt61se8e", gotClaims.Rest.Identifier)
})
+
+ t.Run("should sign new token if org role has changed", func(t *testing.T) {
+ s := ProvideService(
+ setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(),
+ &authntest.FakeService{}, nil,
+ )
+
+ ident := &authn.Identity{
+ ID: "1",
+ Type: claims.TypeUser,
+ AuthenticatedBy: login.AzureADAuthModule,
+ Login: "U1",
+ UID: "edpu3nnt61se8e",
+ OrgID: 1,
+ OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin},
+ }
+
+ first, _, err := s.SignIdentity(context.Background(), ident)
+ require.NoError(t, err)
+
+ second, _, err := s.SignIdentity(context.Background(), ident)
+ require.NoError(t, err)
+
+ assert.Equal(t, first, second)
+
+ ident.OrgRoles[1] = org.RoleEditor
+ third, _, err := s.SignIdentity(context.Background(), ident)
+ require.NoError(t, err)
+ assert.NotEqual(t, first, third)
+ })
}
diff --git a/pkg/services/authn/authnimpl/sync/user_sync.go b/pkg/services/authn/authnimpl/sync/user_sync.go
index 23e8d579b4d..22ec16fa51a 100644
--- a/pkg/services/authn/authnimpl/sync/user_sync.go
+++ b/pkg/services/authn/authnimpl/sync/user_sync.go
@@ -86,29 +86,38 @@ func (s *UserSync) SyncUserHook(ctx context.Context, id *authn.Identity, _ *auth
}
// Does user exist in the database?
- usr, userAuth, errUserInDB := s.getUser(ctx, id)
- if errUserInDB != nil && !errors.Is(errUserInDB, user.ErrUserNotFound) {
- s.log.FromContext(ctx).Error("Failed to fetch user", "error", errUserInDB, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID)
+ usr, userAuth, err := s.getUser(ctx, id)
+ if err != nil && !errors.Is(err, user.ErrUserNotFound) {
+ s.log.FromContext(ctx).Error("Failed to fetch user", "error", err, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID)
return errSyncUserInternal.Errorf("unable to retrieve user")
}
- if errors.Is(errUserInDB, user.ErrUserNotFound) {
+ if errors.Is(err, user.ErrUserNotFound) {
if !id.ClientParams.AllowSignUp {
s.log.FromContext(ctx).Warn("Failed to create user, signup is not allowed for module", "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID)
return errUserSignupDisabled.Errorf("%w", errSignupNotAllowed)
}
// create user
- var errCreate error
- usr, errCreate = s.createUser(ctx, id)
- if errCreate != nil {
- s.log.FromContext(ctx).Error("Failed to create user", "error", errCreate, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID)
- return errSyncUserInternal.Errorf("unable to create user: %w", errCreate)
+ usr, err = s.createUser(ctx, id)
+
+ // There is a possibility for a race condition when creating a user. Most clients will probably not hit this
+ // case but others will. The one we have seen this issue for is auth proxy. First time a new user loads grafana
+ // several requests can get "user.ErrUserNotFound" at the same time but only one of the request will be allowed
+ // to actually create the user, resulting in all other requests getting "user.ErrUserAlreadyExists". So we can
+ // just try to fetch the user one more to make the other request work.
+ if errors.Is(err, user.ErrUserAlreadyExists) {
+ usr, _, err = s.getUser(ctx, id)
+ }
+
+ if err != nil {
+ s.log.FromContext(ctx).Error("Failed to create user", "error", err, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID)
+ return errSyncUserInternal.Errorf("unable to create user: %w", err)
}
} else {
// update user
- if errUpdate := s.updateUserAttributes(ctx, usr, id, userAuth); errUpdate != nil {
- s.log.FromContext(ctx).Error("Failed to update user", "error", errUpdate, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID)
+ if err := s.updateUserAttributes(ctx, usr, id, userAuth); err != nil {
+ s.log.FromContext(ctx).Error("Failed to update user", "error", err, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID)
return errSyncUserInternal.Errorf("unable to update user")
}
}
@@ -311,6 +320,7 @@ func (s *UserSync) updateUserAttributes(ctx context.Context, usr *user.User, id
func (s *UserSync) createUser(ctx context.Context, id *authn.Identity) (*user.User, error) {
ctx, span := s.tracer.Start(ctx, "user.sync.createUser")
defer span.End()
+
// FIXME(jguer): this should be done in the user service
// quota check: we can have quotas on both global and org level
// therefore we need to query check quota for both user and org services
@@ -330,19 +340,18 @@ func (s *UserSync) createUser(ctx context.Context, id *authn.Identity) (*user.Us
isAdmin = *id.IsGrafanaAdmin
}
- usr, errCreateUser := s.userService.Create(ctx, &user.CreateUserCommand{
+ usr, err := s.userService.Create(ctx, &user.CreateUserCommand{
Login: id.Login,
Email: id.Email,
Name: id.Name,
IsAdmin: isAdmin,
SkipOrgSetup: len(id.OrgRoles) > 0,
})
- if errCreateUser != nil {
- return nil, errCreateUser
+ if err != nil {
+ return nil, err
}
- err := s.upsertAuthConnection(ctx, usr.ID, id, true)
- if err != nil {
+ if err := s.upsertAuthConnection(ctx, usr.ID, id, true); err != nil {
return nil, err
}
diff --git a/pkg/services/authn/authnimpl/sync/user_sync_test.go b/pkg/services/authn/authnimpl/sync/user_sync_test.go
index dc6cab243b7..8999a4b6979 100644
--- a/pkg/services/authn/authnimpl/sync/user_sync_test.go
+++ b/pkg/services/authn/authnimpl/sync/user_sync_test.go
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
claims "github.com/grafana/authlib/types"
@@ -451,6 +452,35 @@ func TestUserSync_SyncUserHook(t *testing.T) {
}
}
+func TestUserSync_SyncUserRetryFetch(t *testing.T) {
+ userSrv := usertest.NewMockService(t)
+ userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(nil, user.ErrUserNotFound).Once()
+ userSrv.On("Create", mock.Anything, mock.Anything).Return(nil, user.ErrUserAlreadyExists).Once()
+ userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(&user.User{ID: 1}, nil).Once()
+
+ s := ProvideUserSync(
+ userSrv,
+ authinfoimpl.ProvideOSSUserProtectionService(),
+ &authinfotest.FakeService{},
+ "atest.FakeQuotaService{},
+ tracing.NewNoopTracerService(),
+ featuremgmt.WithFeatures(),
+ )
+
+ email := "test@test.com"
+
+ err := s.SyncUserHook(context.Background(), &authn.Identity{
+ ClientParams: authn.ClientParams{
+ SyncUser: true,
+ AllowSignUp: true,
+ LookUpParams: login.UserLookupParams{
+ Email: &email,
+ },
+ },
+ }, nil)
+ require.NoError(t, err)
+}
+
func TestUserSync_FetchSyncedUserHook(t *testing.T) {
type testCase struct {
desc string
diff --git a/pkg/services/authz/client.go b/pkg/services/authz/rbac.go
similarity index 75%
rename from pkg/services/authz/client.go
rename to pkg/services/authz/rbac.go
index 506155df380..cd0465886d3 100644
--- a/pkg/services/authz/client.go
+++ b/pkg/services/authz/rbac.go
@@ -3,6 +3,8 @@ package authz
import (
"context"
"errors"
+ "fmt"
+ "net/http"
"time"
"github.com/fullstorydev/grpchan"
@@ -11,6 +13,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
+ "k8s.io/client-go/rest"
authnlib "github.com/grafana/authlib/authn"
authzlib "github.com/grafana/authlib/authz"
@@ -22,6 +25,8 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/apiserver"
+ authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/rbac"
"github.com/grafana/grafana/pkg/services/authz/rbac/store"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -64,6 +69,10 @@ func ProvideAuthZClient(
// Register the server
server := rbac.NewService(
sql,
+ // When running in-proc we get a injection cycle between
+ // authz client, resource client and apiserver so we need to use
+ // package level function to get rest config
+ store.NewAPIFolderStore(tracer, apiserver.GetRestConfig),
legacy.NewLegacySQLStores(sql),
store.NewUnionPermissionStore(
store.NewStaticPermissionStore(acService),
@@ -201,3 +210,67 @@ func newCloudLegacyClient(authCfg *Cfg, tracer tracing.Tracer) (authlib.AccessCl
return client, nil
}
+
+func RegisterRBACAuthZService(
+ handler grpcserver.Provider,
+ db legacysql.LegacyDatabaseProvider,
+ tracer tracing.Tracer,
+ reg prometheus.Registerer,
+ cache cache.Cache,
+ exchangeClient authnlib.TokenExchanger,
+ folderAPIURL string,
+) {
+ var folderStore store.FolderStore
+ // FIXME: for now we default to using database read proxy for folders if the api url is not configured.
+ // we should remove this and the sql implementation once we have verified that is works correctly
+ if folderAPIURL == "" {
+ folderStore = store.NewSQLFolderStore(db, tracer)
+ } else {
+ folderStore = store.NewAPIFolderStore(tracer, func(ctx context.Context) *rest.Config {
+ return &rest.Config{
+ Host: folderAPIURL,
+ WrapTransport: func(rt http.RoundTripper) http.RoundTripper {
+ return &tokenExhangeRoundTripper{te: exchangeClient, rt: rt}
+ },
+ QPS: 50,
+ Burst: 100,
+ }
+ })
+ }
+
+ server := rbac.NewService(
+ db,
+ folderStore,
+ legacy.NewLegacySQLStores(db),
+ store.NewSQLPermissionStore(db, tracer),
+ log.New("authz-grpc-server"),
+ tracer,
+ reg,
+ cache,
+ )
+
+ srv := handler.GetServer()
+ authzv1.RegisterAuthzServiceServer(srv, server)
+ authzextv1.RegisterAuthzExtentionServiceServer(srv, server)
+}
+
+var _ http.RoundTripper = tokenExhangeRoundTripper{}
+
+type tokenExhangeRoundTripper struct {
+ te authnlib.TokenExchanger
+ rt http.RoundTripper
+}
+
+func (t tokenExhangeRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
+ res, err := t.te.Exchange(r.Context(), authnlib.TokenExchangeRequest{
+ Namespace: "*",
+ Audiences: []string{"folder.grafana.app"},
+ })
+
+ if err != nil {
+ return nil, fmt.Errorf("create access token: %w", err)
+ }
+
+ r.Header.Set("X-Access-Token", "Bearer "+res.Token)
+ return t.rt.RoundTrip(r)
+}
diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go
index 429b568a091..7a365c0d6f5 100644
--- a/pkg/services/authz/rbac/service.go
+++ b/pkg/services/authz/rbac/service.go
@@ -17,7 +17,7 @@ import (
authzv1 "github.com/grafana/authlib/authz/proto/v1"
"github.com/grafana/authlib/cache"
- claims "github.com/grafana/authlib/types"
+ "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -41,6 +41,7 @@ type Service struct {
authzextv1.UnimplementedAuthzExtentionServiceServer
store store.Store
+ folderStore store.FolderStore
permissionStore store.PermissionStore
identityStore legacy.LegacyIdentityStore
@@ -63,6 +64,7 @@ type Service struct {
func NewService(
sql legacysql.LegacyDatabaseProvider,
+ folderStore store.FolderStore,
identityStore legacy.LegacyIdentityStore,
permissionStore store.PermissionStore,
logger log.Logger,
@@ -72,6 +74,7 @@ func NewService(
) *Service {
return &Service{
store: store.NewStore(sql, tracer),
+ folderStore: folderStore,
permissionStore: permissionStore,
identityStore: identityStore,
logger: logger,
@@ -209,40 +212,42 @@ func (s *Service) validateListRequest(ctx context.Context, req *authzv1.ListRequ
return listReq, nil
}
-func validateNamespace(ctx context.Context, nameSpace string) (claims.NamespaceInfo, error) {
+func validateNamespace(ctx context.Context, nameSpace string) (types.NamespaceInfo, error) {
if nameSpace == "" {
- return claims.NamespaceInfo{}, status.Error(codes.InvalidArgument, "namespace is required")
+ return types.NamespaceInfo{}, status.Error(codes.InvalidArgument, "namespace is required")
}
- authInfo, has := claims.AuthInfoFrom(ctx)
+ authInfo, has := types.AuthInfoFrom(ctx)
if !has {
- return claims.NamespaceInfo{}, status.Error(codes.Internal, "could not get auth info from context")
+ return types.NamespaceInfo{}, status.Error(codes.Internal, "could not get auth info from context")
}
- if !claims.NamespaceMatches(authInfo.GetNamespace(), nameSpace) {
- return claims.NamespaceInfo{}, status.Error(codes.PermissionDenied, "namespace does not match")
+ if !types.NamespaceMatches(authInfo.GetNamespace(), nameSpace) {
+ return types.NamespaceInfo{}, status.Error(codes.PermissionDenied, "namespace does not match")
}
- ns, err := claims.ParseNamespace(nameSpace)
+ ns, err := types.ParseNamespace(nameSpace)
if err != nil {
- return claims.NamespaceInfo{}, err
+ return types.NamespaceInfo{}, err
}
return ns, nil
}
-func (s *Service) validateSubject(ctx context.Context, subject string) (string, claims.IdentityType, error) {
+func (s *Service) validateSubject(ctx context.Context, subject string) (string, types.IdentityType, error) {
if subject == "" {
return "", "", status.Error(codes.InvalidArgument, "subject is required")
}
ctxLogger := s.logger.FromContext(ctx)
- identityType, userUID, err := claims.ParseTypeID(subject)
+ identityType, userUID, err := types.ParseTypeID(subject)
if err != nil {
return "", "", err
}
+
// Permission check currently only checks user, anonymous user, service account and renderer permissions
- if !(identityType == claims.TypeUser || identityType == claims.TypeServiceAccount || identityType == claims.TypeAnonymous || identityType == claims.TypeRenderService) {
+ if !types.IsIdentityType(identityType, types.TypeUser, types.TypeServiceAccount, types.TypeAnonymous, types.TypeRenderService) {
ctxLogger.Error("unsupported identity type", "type", identityType)
return "", "", status.Error(codes.PermissionDenied, "unsupported identity type")
}
+
return userUID, identityType, nil
}
@@ -264,30 +269,29 @@ func (s *Service) validateAction(ctx context.Context, group, resource, verb stri
return action, nil
}
-func (s *Service) getIdentityPermissions(ctx context.Context, ns claims.NamespaceInfo, idType claims.IdentityType, userID, action string) (map[string]bool, error) {
+func (s *Service) getIdentityPermissions(ctx context.Context, ns types.NamespaceInfo, idType types.IdentityType, userID, action string) (map[string]bool, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getIdentityPermissions")
defer span.End()
// When checking folder creation permissions, also check edit and admin action sets for folder, as the scoped folder create actions aren't stored in the DB separately
var actionSets []string
if action == "folders:create" {
- actionSets = append(actionSets, "folders:edit")
- actionSets = append(actionSets, "folders:admin")
+ actionSets = append(actionSets, "folders:edit", "folders:admin")
}
switch idType {
- case claims.TypeAnonymous:
+ case types.TypeAnonymous:
return s.getAnonymousPermissions(ctx, ns, action, actionSets)
- case claims.TypeRenderService:
+ case types.TypeRenderService:
return s.getRendererPermissions(ctx, action)
- case claims.TypeUser, claims.TypeServiceAccount:
+ case types.TypeUser, types.TypeServiceAccount:
return s.getUserPermissions(ctx, ns, userID, action, actionSets)
default:
return nil, fmt.Errorf("unsupported identity type: %s", idType)
}
}
-func (s *Service) getUserPermissions(ctx context.Context, ns claims.NamespaceInfo, userID, action string, actionSets []string) (map[string]bool, error) {
+func (s *Service) getUserPermissions(ctx context.Context, ns types.NamespaceInfo, userID, action string, actionSets []string) (map[string]bool, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getUserPermissions")
defer span.End()
@@ -342,7 +346,7 @@ func (s *Service) getUserPermissions(ctx context.Context, ns claims.NamespaceInf
return res.(map[string]bool), nil
}
-func (s *Service) getAnonymousPermissions(ctx context.Context, ns claims.NamespaceInfo, action string, actionSets []string) (map[string]bool, error) {
+func (s *Service) getAnonymousPermissions(ctx context.Context, ns types.NamespaceInfo, action string, actionSets []string) (map[string]bool, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getAnonymousPermissions")
defer span.End()
@@ -378,7 +382,7 @@ func (s *Service) getRendererPermissions(ctx context.Context, action string) (ma
return map[string]bool{}, nil
}
-func (s *Service) GetUserIdentifiers(ctx context.Context, ns claims.NamespaceInfo, userUID string) (*store.UserIdentifiers, error) {
+func (s *Service) GetUserIdentifiers(ctx context.Context, ns types.NamespaceInfo, userUID string) (*store.UserIdentifiers, error) {
uidCacheKey := userIdentifierCacheKey(ns.Value, userUID)
if cached, ok := s.idCache.Get(ctx, uidCacheKey); ok {
return &cached, nil
@@ -397,7 +401,7 @@ func (s *Service) GetUserIdentifiers(ctx context.Context, ns claims.NamespaceInf
userIDQuery = store.UserIdentifierQuery{UserUID: userUID}
}
userIdentifiers, err := s.store.GetUserIdentifiers(ctx, userIDQuery)
- if err != nil || userIdentifiers == nil {
+ if err != nil {
return nil, fmt.Errorf("could not get user internal id: %w", err)
}
@@ -407,7 +411,7 @@ func (s *Service) GetUserIdentifiers(ctx context.Context, ns claims.NamespaceInf
return userIdentifiers, nil
}
-func (s *Service) getUserTeams(ctx context.Context, ns claims.NamespaceInfo, userIdentifiers *store.UserIdentifiers) ([]int64, error) {
+func (s *Service) getUserTeams(ctx context.Context, ns types.NamespaceInfo, userIdentifiers *store.UserIdentifiers) ([]int64, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getUserTeams")
defer span.End()
@@ -441,7 +445,7 @@ func (s *Service) getUserTeams(ctx context.Context, ns claims.NamespaceInfo, use
return teamIDs, nil
}
-func (s *Service) getUserBasicRole(ctx context.Context, ns claims.NamespaceInfo, userIdentifiers *store.UserIdentifiers) (store.BasicRole, error) {
+func (s *Service) getUserBasicRole(ctx context.Context, ns types.NamespaceInfo, userIdentifiers *store.UserIdentifiers) (store.BasicRole, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getUserBasicRole")
defer span.End()
@@ -535,7 +539,7 @@ func (s *Service) checkInheritedPermissions(ctx context.Context, scopeMap map[st
return false, nil
}
-func (s *Service) buildFolderTree(ctx context.Context, ns claims.NamespaceInfo) (folderTree, error) {
+func (s *Service) buildFolderTree(ctx context.Context, ns types.NamespaceInfo) (folderTree, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.buildFolderTree")
defer span.End()
@@ -545,7 +549,7 @@ func (s *Service) buildFolderTree(ctx context.Context, ns claims.NamespaceInfo)
}
res, err, _ := s.sf.Do(ns.Value+"_buildFolderTree", func() (interface{}, error) {
- folders, err := s.store.GetFolders(ctx, ns)
+ folders, err := s.folderStore.ListFolders(ctx, ns)
if err != nil {
return nil, fmt.Errorf("could not get folders: %w", err)
}
diff --git a/pkg/services/authz/rbac/service_test.go b/pkg/services/authz/rbac/service_test.go
index 310e7cc8cfc..930f5920e36 100644
--- a/pkg/services/authz/rbac/service_test.go
+++ b/pkg/services/authz/rbac/service_test.go
@@ -620,6 +620,7 @@ func setupService() *Service {
folderCache: newCacheWrap[folderTree](cache, logger, shortCacheTTL),
store: fStore,
permissionStore: fStore,
+ folderStore: fStore,
identityStore: &fakeIdentityStore{},
sf: new(singleflight.Group),
}
@@ -663,7 +664,7 @@ func (f *fakeStore) GetUserPermissions(ctx context.Context, namespace claims.Nam
return f.userPermissions, nil
}
-func (f *fakeStore) GetFolders(ctx context.Context, namespace claims.NamespaceInfo) ([]store.Folder, error) {
+func (f *fakeStore) ListFolders(ctx context.Context, namespace claims.NamespaceInfo) ([]store.Folder, error) {
f.calls++
if f.err {
return nil, fmt.Errorf("store error")
diff --git a/pkg/services/authz/rbac/store/folder_store.go b/pkg/services/authz/rbac/store/folder_store.go
new file mode 100644
index 00000000000..078cb2f4c61
--- /dev/null
+++ b/pkg/services/authz/rbac/store/folder_store.go
@@ -0,0 +1,158 @@
+package store
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/grafana/authlib/types"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/client-go/dynamic"
+ "k8s.io/client-go/rest"
+ "k8s.io/client-go/tools/pager"
+
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ "github.com/grafana/grafana/pkg/infra/tracing"
+ "github.com/grafana/grafana/pkg/storage/legacysql"
+ "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
+)
+
+type FolderStore interface {
+ ListFolders(ctx context.Context, ns types.NamespaceInfo) ([]Folder, error)
+}
+
+type Folder struct {
+ UID string
+ ParentUID *string
+}
+
+var _ FolderStore = (*SQLFolderStore)(nil)
+
+func NewSQLFolderStore(sql legacysql.LegacyDatabaseProvider, tracer tracing.Tracer) *SQLFolderStore {
+ return &SQLFolderStore{sql, tracer}
+}
+
+type SQLFolderStore struct {
+ sql legacysql.LegacyDatabaseProvider
+ tracer tracing.Tracer
+}
+
+var sqlFolders = mustTemplate("folder_query.sql")
+
+type listFoldersQuery struct {
+ sqltemplate.SQLTemplate
+
+ Query *FolderQuery
+ FolderTable string
+}
+
+type FolderQuery struct {
+ OrgID int64
+}
+
+func (r listFoldersQuery) Validate() error {
+ return nil
+}
+
+func newListFolders(sql *legacysql.LegacyDatabaseHelper, query *FolderQuery) listFoldersQuery {
+ return listFoldersQuery{
+ SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
+ Query: query,
+ FolderTable: sql.Table("folder"),
+ }
+}
+
+func (s *SQLFolderStore) ListFolders(ctx context.Context, ns types.NamespaceInfo) ([]Folder, error) {
+ ctx, span := s.tracer.Start(ctx, "authz_direct_db.database.ListFolders")
+ defer span.End()
+
+ sql, err := s.sql(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ query := newListFolders(sql, &FolderQuery{OrgID: ns.OrgID})
+ q, err := sqltemplate.Execute(sqlFolders, query)
+ if err != nil {
+ return nil, err
+ }
+
+ rows, err := sql.DB.GetSqlxSession().Query(ctx, q, query.GetArgs()...)
+ defer func() {
+ if rows != nil {
+ _ = rows.Close()
+ }
+ }()
+ if err != nil {
+ return nil, err
+ }
+
+ var folders []Folder
+ for rows.Next() {
+ var folder Folder
+ if err := rows.Scan(&folder.UID, &folder.ParentUID); err != nil {
+ return nil, err
+ }
+ folders = append(folders, folder)
+ }
+
+ return folders, nil
+}
+
+var _ FolderStore = (*APIFolderStore)(nil)
+
+func NewAPIFolderStore(tracer tracing.Tracer, configProvider func(ctx context.Context) *rest.Config) *APIFolderStore {
+ return &APIFolderStore{tracer, configProvider}
+}
+
+type APIFolderStore struct {
+ tracer tracing.Tracer
+ configProvider func(ctx context.Context) *rest.Config
+}
+
+func (s *APIFolderStore) ListFolders(ctx context.Context, ns types.NamespaceInfo) ([]Folder, error) {
+ ctx, span := s.tracer.Start(ctx, "authz.apistore.ListFolders")
+ defer span.End()
+
+ client, err := s.client(ctx, ns.Value)
+ if err != nil {
+ return nil, fmt.Errorf("create resource client: %w", err)
+ }
+
+ p := pager.New(func(ctx context.Context, opts metav1.ListOptions) (runtime.Object, error) {
+ return client.List(ctx, opts)
+ })
+
+ const defaultPageSize = 500
+ folders := make([]Folder, 0, defaultPageSize)
+ err = p.EachListItem(ctx, metav1.ListOptions{Limit: defaultPageSize}, func(obj runtime.Object) error {
+ object, err := utils.MetaAccessor(obj)
+ if err != nil {
+ return err
+ }
+
+ folder := Folder{UID: object.GetName()}
+ parent := object.GetFolder()
+ if parent != "" {
+ folder.ParentUID = &parent
+ }
+
+ folders = append(folders, folder)
+ return nil
+ })
+
+ if err != nil {
+ return nil, fmt.Errorf("fetching folders: %w", err)
+ }
+
+ return folders, nil
+}
+
+func (s *APIFolderStore) client(ctx context.Context, namespace string) (dynamic.ResourceInterface, error) {
+ client, err := dynamic.NewForConfig(s.configProvider(ctx))
+ if err != nil {
+ return nil, err
+ }
+ return client.Resource(folderv0alpha1.FolderResourceInfo.GroupVersionResource()).Namespace(namespace), nil
+}
diff --git a/pkg/services/authz/rbac/store/models.go b/pkg/services/authz/rbac/store/models.go
index 9abcbb8a257..bc3e6593245 100644
--- a/pkg/services/authz/rbac/store/models.go
+++ b/pkg/services/authz/rbac/store/models.go
@@ -19,21 +19,3 @@ type UserIdentifierQuery struct {
UserID int64
UserUID string
}
-
-type FolderQuery struct {
- OrgID int64
-}
-
-type DashboardQuery struct {
- OrgID int64
-}
-
-type Folder struct {
- UID string
- ParentUID *string
-}
-
-type Dashboard struct {
- UID string
- ParentUID *string
-}
diff --git a/pkg/services/authz/rbac/store/queries.go b/pkg/services/authz/rbac/store/queries.go
index 4ad0a3d5e11..10c9a17ea8a 100644
--- a/pkg/services/authz/rbac/store/queries.go
+++ b/pkg/services/authz/rbac/store/queries.go
@@ -16,7 +16,6 @@ var (
sqlQueryBasicRoles = mustTemplate("basic_role_query.sql")
sqlUserIdentifiers = mustTemplate("user_identifier_query.sql")
- sqlFolders = mustTemplate("folder_query.sql")
)
func mustTemplate(filename string) *template.Template {
diff --git a/pkg/services/authz/rbac/store/store.go b/pkg/services/authz/rbac/store/store.go
index 9b7c2832b00..124ba38331b 100644
--- a/pkg/services/authz/rbac/store/store.go
+++ b/pkg/services/authz/rbac/store/store.go
@@ -15,7 +15,6 @@ import (
type Store interface {
GetUserIdentifiers(ctx context.Context, query UserIdentifierQuery) (*UserIdentifiers, error)
GetBasicRoles(ctx context.Context, ns claims.NamespaceInfo, query BasicRoleQuery) (*BasicRole, error)
- GetFolders(ctx context.Context, ns claims.NamespaceInfo) ([]Folder, error)
}
type StoreImpl struct {
@@ -104,41 +103,3 @@ func (s *StoreImpl) GetBasicRoles(ctx context.Context, ns claims.NamespaceInfo,
return &role, nil
}
-
-func (s *StoreImpl) GetFolders(ctx context.Context, ns claims.NamespaceInfo) ([]Folder, error) {
- ctx, span := s.tracer.Start(ctx, "authz_direct_db.database.GetFolders")
- defer span.End()
-
- sql, err := s.sql(ctx)
- if err != nil {
- return nil, err
- }
-
- query := FolderQuery{OrgID: ns.OrgID}
- req := newGetFolders(sql, &query)
- q, err := sqltemplate.Execute(sqlFolders, req)
- if err != nil {
- return nil, err
- }
-
- rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
- defer func() {
- if rows != nil {
- _ = rows.Close()
- }
- }()
- if err != nil {
- return nil, err
- }
-
- var folders []Folder
- for rows.Next() {
- var folder Folder
- if err := rows.Scan(&folder.UID, &folder.ParentUID); err != nil {
- return nil, err
- }
- folders = append(folders, folder)
- }
-
- return folders, nil
-}
diff --git a/pkg/services/authz/server.go b/pkg/services/authz/server.go
deleted file mode 100644
index 868e7c2816b..00000000000
--- a/pkg/services/authz/server.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package authz
-
-import (
- authzv1 "github.com/grafana/authlib/authz/proto/v1"
- cache "github.com/grafana/authlib/cache"
-
- "github.com/grafana/grafana/pkg/infra/log"
- "github.com/grafana/grafana/pkg/infra/tracing"
- "github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
- authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
- "github.com/grafana/grafana/pkg/services/authz/rbac"
- "github.com/grafana/grafana/pkg/services/authz/rbac/store"
- "github.com/grafana/grafana/pkg/services/grpcserver"
- "github.com/grafana/grafana/pkg/storage/legacysql"
- "github.com/prometheus/client_golang/prometheus"
-)
-
-func RegisterRBACAuthZService(
- handler grpcserver.Provider,
- db legacysql.LegacyDatabaseProvider,
- tracer tracing.Tracer,
- reg prometheus.Registerer,
- cache cache.Cache) {
- server := rbac.NewService(
- db,
- legacy.NewLegacySQLStores(db),
- store.NewSQLPermissionStore(db, tracer),
- log.New("authz-grpc-server"),
- tracer,
- reg,
- cache,
- )
-
- srv := handler.GetServer()
- authzv1.RegisterAuthzServiceServer(srv, server)
- authzextv1.RegisterAuthzExtentionServiceServer(srv, server)
-}
diff --git a/pkg/services/dashboardimport/service/testdata/dashboard.json b/pkg/services/dashboardimport/service/testdata/dashboard.json
index 401d2e2676a..9358c6b40fb 100644
--- a/pkg/services/dashboardimport/service/testdata/dashboard.json
+++ b/pkg/services/dashboardimport/service/testdata/dashboard.json
@@ -209,8 +209,7 @@
},
"timepicker": {
"now": true,
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "browser",
"title": "Prometheus 2.0 Stats",
diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go
index 8d56c056afa..2561b74e987 100644
--- a/pkg/services/dashboards/dashboard.go
+++ b/pkg/services/dashboards/dashboard.go
@@ -81,6 +81,8 @@ type Store interface {
GetProvisionedDashboardData(ctx context.Context, name string) ([]*DashboardProvisioning, error)
GetProvisionedDataByDashboardID(ctx context.Context, dashboardID int64) (*DashboardProvisioning, error)
GetProvisionedDataByDashboardUID(ctx context.Context, orgID int64, dashboardUID string) (*DashboardProvisioning, error)
+ GetProvisionedDashboardsByName(ctx context.Context, name string) ([]*Dashboard, error)
+ GetOrphanedProvisionedDashboards(ctx context.Context, notIn []string) ([]*Dashboard, error)
SaveDashboard(ctx context.Context, cmd SaveDashboardCommand) (*Dashboard, error)
SaveProvisionedDashboard(ctx context.Context, dash *Dashboard, provisioning *DashboardProvisioning) error
UnprovisionDashboard(ctx context.Context, id int64) error
diff --git a/pkg/services/dashboards/dashboard_provisioning_mock.go b/pkg/services/dashboards/dashboard_provisioning_mock.go
index a743752fcd5..ce7893a24a9 100644
--- a/pkg/services/dashboards/dashboard_provisioning_mock.go
+++ b/pkg/services/dashboards/dashboard_provisioning_mock.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.32.0. DO NOT EDIT.
+// Code generated by mockery v2.52.2. DO NOT EDIT.
package dashboards
@@ -18,6 +18,10 @@ type FakeDashboardProvisioning struct {
func (_m *FakeDashboardProvisioning) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *DeleteOrphanedProvisionedDashboardsCommand) error {
ret := _m.Called(ctx, cmd)
+ if len(ret) == 0 {
+ panic("no return value specified for DeleteOrphanedProvisionedDashboards")
+ }
+
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *DeleteOrphanedProvisionedDashboardsCommand) error); ok {
r0 = rf(ctx, cmd)
@@ -32,6 +36,10 @@ func (_m *FakeDashboardProvisioning) DeleteOrphanedProvisionedDashboards(ctx con
func (_m *FakeDashboardProvisioning) DeleteProvisionedDashboard(ctx context.Context, dashboardID int64, orgID int64) error {
ret := _m.Called(ctx, dashboardID, orgID)
+ if len(ret) == 0 {
+ panic("no return value specified for DeleteProvisionedDashboard")
+ }
+
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int64, int64) error); ok {
r0 = rf(ctx, dashboardID, orgID)
@@ -46,6 +54,10 @@ func (_m *FakeDashboardProvisioning) DeleteProvisionedDashboard(ctx context.Cont
func (_m *FakeDashboardProvisioning) GetProvisionedDashboardData(ctx context.Context, name string) ([]*DashboardProvisioning, error) {
ret := _m.Called(ctx, name)
+ if len(ret) == 0 {
+ panic("no return value specified for GetProvisionedDashboardData")
+ }
+
var r0 []*DashboardProvisioning
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) ([]*DashboardProvisioning, error)); ok {
@@ -72,6 +84,10 @@ func (_m *FakeDashboardProvisioning) GetProvisionedDashboardData(ctx context.Con
func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardID(ctx context.Context, dashboardID int64) (*DashboardProvisioning, error) {
ret := _m.Called(ctx, dashboardID)
+ if len(ret) == 0 {
+ panic("no return value specified for GetProvisionedDashboardDataByDashboardID")
+ }
+
var r0 *DashboardProvisioning
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, int64) (*DashboardProvisioning, error)); ok {
@@ -98,6 +114,10 @@ func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardID(ct
func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardUID(ctx context.Context, orgID int64, dashboardUID string) (*DashboardProvisioning, error) {
ret := _m.Called(ctx, orgID, dashboardUID)
+ if len(ret) == 0 {
+ panic("no return value specified for GetProvisionedDashboardDataByDashboardUID")
+ }
+
var r0 *DashboardProvisioning
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, int64, string) (*DashboardProvisioning, error)); ok {
@@ -124,6 +144,10 @@ func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardUID(c
func (_m *FakeDashboardProvisioning) SaveFolderForProvisionedDashboards(_a0 context.Context, _a1 *folder.CreateFolderCommand) (*folder.Folder, error) {
ret := _m.Called(_a0, _a1)
+ if len(ret) == 0 {
+ panic("no return value specified for SaveFolderForProvisionedDashboards")
+ }
+
var r0 *folder.Folder
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *folder.CreateFolderCommand) (*folder.Folder, error)); ok {
@@ -150,6 +174,10 @@ func (_m *FakeDashboardProvisioning) SaveFolderForProvisionedDashboards(_a0 cont
func (_m *FakeDashboardProvisioning) SaveProvisionedDashboard(ctx context.Context, dto *SaveDashboardDTO, provisioning *DashboardProvisioning) (*Dashboard, error) {
ret := _m.Called(ctx, dto, provisioning)
+ if len(ret) == 0 {
+ panic("no return value specified for SaveProvisionedDashboard")
+ }
+
var r0 *Dashboard
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *SaveDashboardDTO, *DashboardProvisioning) (*Dashboard, error)); ok {
@@ -176,6 +204,10 @@ func (_m *FakeDashboardProvisioning) SaveProvisionedDashboard(ctx context.Contex
func (_m *FakeDashboardProvisioning) UnprovisionDashboard(ctx context.Context, dashboardID int64) error {
ret := _m.Called(ctx, dashboardID)
+ if len(ret) == 0 {
+ panic("no return value specified for UnprovisionDashboard")
+ }
+
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok {
r0 = rf(ctx, dashboardID)
diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go
index af8a6184e2a..2e37ea6f3b8 100644
--- a/pkg/services/dashboards/dashboard_service_mock.go
+++ b/pkg/services/dashboards/dashboard_service_mock.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.42.2. DO NOT EDIT.
+// Code generated by mockery v2.52.2. DO NOT EDIT.
package dashboards
@@ -74,6 +74,34 @@ func (_m *FakeDashboardService) CleanUpDeletedDashboards(ctx context.Context) (i
return r0, r1
}
+// CountDashboardsInOrg provides a mock function with given fields: ctx, orgID
+func (_m *FakeDashboardService) CountDashboardsInOrg(ctx context.Context, orgID int64) (int64, error) {
+ ret := _m.Called(ctx, orgID)
+
+ if len(ret) == 0 {
+ panic("no return value specified for CountDashboardsInOrg")
+ }
+
+ var r0 int64
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, int64) (int64, error)); ok {
+ return rf(ctx, orgID)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, int64) int64); ok {
+ r0 = rf(ctx, orgID)
+ } else {
+ r0 = ret.Get(0).(int64)
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
+ r1 = rf(ctx, orgID)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
// CountInFolders provides a mock function with given fields: ctx, orgID, folderUIDs, user
func (_m *FakeDashboardService) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) (int64, error) {
ret := _m.Called(ctx, orgID, folderUIDs, user)
@@ -102,6 +130,24 @@ func (_m *FakeDashboardService) CountInFolders(ctx context.Context, orgID int64,
return r0, r1
}
+// DeleteAllDashboards provides a mock function with given fields: ctx, orgID
+func (_m *FakeDashboardService) DeleteAllDashboards(ctx context.Context, orgID int64) error {
+ ret := _m.Called(ctx, orgID)
+
+ if len(ret) == 0 {
+ panic("no return value specified for DeleteAllDashboards")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok {
+ r0 = rf(ctx, orgID)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
// DeleteDashboard provides a mock function with given fields: ctx, dashboardId, dashboardUID, orgId
func (_m *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId int64, dashboardUID string, orgId int64) error {
ret := _m.Called(ctx, dashboardId, dashboardUID, orgId)
@@ -120,24 +166,6 @@ func (_m *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId
return r0
}
-// DeleteAllDashboards provides a mock function with given fields: ctx, orgID
-func (_m *FakeDashboardService) DeleteAllDashboards(ctx context.Context, orgID int64) error {
- ret := _m.Called(ctx, orgID)
-
- if len(ret) == 0 {
- panic("no return value specified for DeleteDashboard")
- }
-
- var r0 error
- if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok {
- r0 = rf(ctx, orgID)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
// FindDashboards provides a mock function with given fields: ctx, query
func (_m *FakeDashboardService) FindDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) {
ret := _m.Called(ctx, query)
@@ -168,36 +196,6 @@ func (_m *FakeDashboardService) FindDashboards(ctx context.Context, query *FindP
return r0, r1
}
-// CountDashboardsInOrg provides a mock function with given fields: ctx, orgID
-func (_m *FakeDashboardService) CountDashboardsInOrg(ctx context.Context, orgID int64) (int64, error) {
- ret := _m.Called(ctx, orgID)
-
- if len(ret) == 0 {
- panic("no return value specified for CountDashboardsInOrg")
- }
-
- var r0 int64
- var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, int64) (int64, error)); ok {
- return rf(ctx, orgID)
- }
- if rf, ok := ret.Get(0).(func(context.Context, int64) int64); ok {
- r0 = rf(ctx, orgID)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(int64)
- }
- }
-
- if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
- r1 = rf(ctx, orgID)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
// GetAllDashboards provides a mock function with given fields: ctx
func (_m *FakeDashboardService) GetAllDashboards(ctx context.Context) ([]*Dashboard, error) {
ret := _m.Called(ctx)
@@ -228,6 +226,7 @@ func (_m *FakeDashboardService) GetAllDashboards(ctx context.Context) ([]*Dashbo
return r0, r1
}
+// GetAllDashboardsByOrgId provides a mock function with given fields: ctx, orgID
func (_m *FakeDashboardService) GetAllDashboardsByOrgId(ctx context.Context, orgID int64) ([]*Dashboard, error) {
ret := _m.Called(ctx, orgID)
diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go
index a9dd51c56a0..06c0c1a0550 100644
--- a/pkg/services/dashboards/database/database.go
+++ b/pkg/services/dashboards/database/database.go
@@ -146,6 +146,38 @@ func (d *dashboardStore) GetProvisionedDashboardData(ctx context.Context, name s
return result, err
}
+func (d *dashboardStore) GetProvisionedDashboardsByName(ctx context.Context, name string) ([]*dashboards.Dashboard, error) {
+ ctx, span := tracer.Start(ctx, "dashboards.database.GetProvisionedDashboardsByName")
+ defer span.End()
+
+ dashes := []*dashboards.Dashboard{}
+ err := d.store.WithDbSession(ctx, func(sess *db.Session) error {
+ return sess.Table(`dashboard`).
+ Join(`INNER`, `dashboard_provisioning`, `dashboard.id = dashboard_provisioning.dashboard_id`).
+ Where(`dashboard_provisioning.name = ?`, name).Find(&dashes)
+ })
+ if err != nil {
+ return nil, err
+ }
+ return dashes, nil
+}
+
+func (d *dashboardStore) GetOrphanedProvisionedDashboards(ctx context.Context, notIn []string) ([]*dashboards.Dashboard, error) {
+ ctx, span := tracer.Start(ctx, "dashboards.database.GetOrphanedProvisionedDashboards")
+ defer span.End()
+
+ dashes := []*dashboards.Dashboard{}
+ err := d.store.WithDbSession(ctx, func(sess *db.Session) error {
+ return sess.Table(`dashboard`).
+ Join(`INNER`, `dashboard_provisioning`, `dashboard.id = dashboard_provisioning.dashboard_id`).
+ NotIn(`dashboard_provisioning.name`, notIn).Find(&dashes)
+ })
+ if err != nil {
+ return nil, err
+ }
+ return dashes, nil
+}
+
func (d *dashboardStore) SaveProvisionedDashboard(ctx context.Context, dash *dashboards.Dashboard, provisioning *dashboards.DashboardProvisioning) error {
ctx, span := tracer.Start(ctx, "dashboards.database.SaveProvisionedDashboard")
defer span.End()
diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go
index ff33a306a9e..79b94fa7ab7 100644
--- a/pkg/services/dashboards/database/database_test.go
+++ b/pkg/services/dashboards/database/database_test.go
@@ -251,26 +251,51 @@ func TestIntegrationDashboardDataAccess(t *testing.T) {
t.Run("Should delete associated provisioning info, even without the dashboard existing in the db", func(t *testing.T) {
setup()
+ dash1 := insertTestDashboard(t, dashboardStore, "provisioned", 1, 0, "", false, "provisioned")
+ dash2 := insertTestDashboard(t, dashboardStore, "orphaned", 1, 0, "", false, "orphaned")
provisioningData := &dashboards.DashboardProvisioning{
ID: 1,
- DashboardID: 200,
+ DashboardID: dash1.ID,
Name: "test",
CheckSum: "123",
Updated: 54321,
ExternalID: "/path/to/dashboard",
}
- err := dashboardStore.SaveProvisionedDashboard(context.Background(), &dashboards.Dashboard{
- ID: 200,
- }, provisioningData)
+ err := dashboardStore.SaveProvisionedDashboard(context.Background(), dash1, provisioningData)
+ require.NoError(t, err)
+ provisioningData2 := &dashboards.DashboardProvisioning{
+ ID: 1,
+ DashboardID: dash2.ID,
+ Name: "orphaned",
+ CheckSum: "123",
+ Updated: 54321,
+ ExternalID: "/path/to/dashboard",
+ }
+ err = dashboardStore.SaveProvisionedDashboard(context.Background(), dash2, provisioningData2)
require.NoError(t, err)
+ // get provisioning data
res, err := dashboardStore.GetProvisionedDashboardData(context.Background(), "test")
require.NoError(t, err)
require.Len(t, res, 1)
require.Equal(t, res[0], provisioningData)
+ // get dashboards within the provisioner
+ dashs, err := dashboardStore.GetProvisionedDashboardsByName(context.Background(), "test")
+ require.NoError(t, err)
+ require.Len(t, dashs, 1)
+
+ // find dashboards not within that provisioner
+ dashs, err = dashboardStore.GetOrphanedProvisionedDashboards(context.Background(), []string{"test"})
+ require.NoError(t, err)
+ require.Len(t, dashs, 1)
+ // if both are provided, nothing should be returned
+ dashs, err = dashboardStore.GetOrphanedProvisionedDashboards(context.Background(), []string{"test", "orphaned"})
+ require.NoError(t, err)
+ require.Len(t, dashs, 0)
+
err = dashboardStore.CleanupAfterDelete(context.Background(), &dashboards.DeleteDashboardCommand{
- ID: 200,
+ ID: dash1.ID,
OrgID: 1,
UID: "test",
})
diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go
index 3964922fcda..eea8dd574ad 100644
--- a/pkg/services/dashboards/service/dashboard_service.go
+++ b/pkg/services/dashboards/service/dashboard_service.go
@@ -958,7 +958,7 @@ func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, que
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) {
dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
OrgId: query.OrgID,
- ProvisionedRepo: pluginIDRepoName,
+ ProvisionedRepo: dashboard.PluginIDRepoName,
ProvisionedPath: query.PluginID,
})
if err != nil {
@@ -1559,7 +1559,7 @@ func (dr *DashboardServiceImpl) saveProvisionedDashboardThroughK8s(ctx context.C
delete(annotations, utils.AnnoKeyRepoHash)
delete(annotations, utils.AnnoKeyRepoTimestamp)
} else {
- annotations[utils.AnnoKeyRepoName] = provisionedFileNameWithPrefix(provisioning.Name)
+ annotations[utils.AnnoKeyRepoName] = dashboard.ProvisionedFileNameWithPrefix(provisioning.Name)
annotations[utils.AnnoKeyRepoPath] = provisioning.ExternalID
annotations[utils.AnnoKeyRepoHash] = provisioning.CheckSum
annotations[utils.AnnoKeyRepoTimestamp] = time.Unix(provisioning.Updated, 0).UTC().Format(time.RFC3339)
@@ -1580,7 +1580,7 @@ func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd
return nil, err
}
- setPluginID(obj, cmd.PluginID)
+ dashboard.SetPluginIDMeta(obj, cmd.PluginID)
out, err := dr.createOrUpdateDash(ctx, obj, orgID)
if err != nil {
@@ -1755,6 +1755,10 @@ func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Contex
request.IsDeleted = query.IsDeleted
}
+ if query.Permission > 0 {
+ request.Permission = int64(query.Permission)
+ }
+
if query.Limit < 1 {
query.Limit = 1000
}
@@ -1823,13 +1827,13 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex
ctx, _ = identity.WithServiceIdentity(ctx, query.OrgId)
if query.ProvisionedRepo != "" {
- query.ProvisionedRepo = provisionedFileNameWithPrefix(query.ProvisionedRepo)
+ query.ProvisionedRepo = dashboard.ProvisionedFileNameWithPrefix(query.ProvisionedRepo)
}
if len(query.ProvisionedReposNotIn) > 0 {
repos := make([]string, len(query.ProvisionedReposNotIn))
for i, v := range query.ProvisionedReposNotIn {
- repos[i] = provisionedFileNameWithPrefix(v)
+ repos[i] = dashboard.ProvisionedFileNameWithPrefix(v)
}
query.ProvisionedReposNotIn = repos
}
@@ -1861,7 +1865,7 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex
}
// ensure the repo is set due to file provisioning, otherwise skip it
- fileRepo, found := getProvisionedFileNameFromMeta(meta)
+ fileRepo, found := dashboard.GetProvisionedFileNameFromMeta(meta.GetRepositoryName())
if !found {
return nil
}
@@ -1963,7 +1967,7 @@ func (dr *DashboardServiceImpl) UnstructuredToLegacyDashboard(ctx context.Contex
out.Deleted = obj.GetDeletionTimestamp().Time
}
- out.PluginID = GetPluginIDFromMeta(obj)
+ out.PluginID = dashboard.GetPluginIDFromMeta(obj)
creator, err := dr.k8sclient.GetUserFromMeta(ctx, obj.GetCreatedBy())
if err != nil {
@@ -2008,42 +2012,6 @@ func (dr *DashboardServiceImpl) UnstructuredToLegacyDashboard(ctx context.Contex
return &out, nil
}
-var pluginIDRepoName = "plugin"
-var fileProvisionedRepoPrefix = "file:"
-
-func setPluginID(obj unstructured.Unstructured, pluginID string) {
- if pluginID == "" {
- return
- }
-
- annotations := obj.GetAnnotations()
- if annotations == nil {
- annotations = map[string]string{}
- }
- annotations[utils.AnnoKeyRepoName] = pluginIDRepoName
- annotations[utils.AnnoKeyRepoPath] = pluginID
- obj.SetAnnotations(annotations)
-}
-
-func provisionedFileNameWithPrefix(name string) string {
- if name == "" {
- return ""
- }
-
- return fileProvisionedRepoPrefix + name
-}
-
-func getProvisionedFileNameFromMeta(obj utils.GrafanaMetaAccessor) (string, bool) {
- return strings.CutPrefix(obj.GetRepositoryName(), fileProvisionedRepoPrefix)
-}
-
-func GetPluginIDFromMeta(obj utils.GrafanaMetaAccessor) string {
- if obj.GetRepositoryName() == pluginIDRepoName {
- return obj.GetRepositoryPath()
- }
- return ""
-}
-
func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, namespace string) (unstructured.Unstructured, error) {
uid := cmd.GetDashboardModel().UID
if uid == "" {
diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go
index ccfa8f9e3a3..68ebc0ac61a 100644
--- a/pkg/services/dashboards/service/dashboard_service_test.go
+++ b/pkg/services/dashboards/service/dashboard_service_test.go
@@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
+ "github.com/grafana/grafana/pkg/apis/dashboard"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver/client"
@@ -547,7 +548,7 @@ func TestGetProvisionedDashboardData(t *testing.T) {
utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck
},
"annotations": map[string]any{
- utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "test",
+ utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"),
utils.AnnoKeyRepoHash: "hash",
utils.AnnoKeyRepoPath: "path/to/file",
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
@@ -563,7 +564,7 @@ func TestGetProvisionedDashboardData(t *testing.T) {
k8sCliMock.On("Search", mock.Anything, int64(1),
mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
// ensure the prefix is added to the query
- return req.Options.Fields[0].Values[0] == provisionedFileNameWithPrefix(repo)
+ return req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix(repo)
})).Return(&resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{},
@@ -573,7 +574,7 @@ func TestGetProvisionedDashboardData(t *testing.T) {
}, nil).Once()
k8sCliMock.On("Search", mock.Anything, int64(2), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
// ensure the prefix is added to the query
- return req.Options.Fields[0].Values[0] == provisionedFileNameWithPrefix(repo)
+ return req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix(repo)
})).Return(&resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
@@ -646,7 +647,7 @@ func TestGetProvisionedDashboardDataByDashboardID(t *testing.T) {
utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck
},
"annotations": map[string]any{
- utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "test",
+ utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"),
utils.AnnoKeyRepoHash: "hash",
utils.AnnoKeyRepoPath: "path/to/file",
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
@@ -736,7 +737,7 @@ func TestGetProvisionedDashboardDataByDashboardUID(t *testing.T) {
utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck
},
"annotations": map[string]any{
- utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "test",
+ utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"),
utils.AnnoKeyRepoHash: "hash",
utils.AnnoKeyRepoPath: "path/to/file",
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
@@ -825,7 +826,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
"metadata": map[string]any{
"name": "uid",
"annotations": map[string]any{
- utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "orphaned",
+ utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("orphaned"),
utils.AnnoKeyRepoHash: "hash",
utils.AnnoKeyRepoPath: "path/to/file",
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
@@ -838,7 +839,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
"metadata": map[string]any{
"name": "uid2",
"annotations": map[string]any{
- utils.AnnoKeyRepoName: "plugin",
+ utils.AnnoKeyRepoName: dashboard.PluginIDRepoName,
utils.AnnoKeyRepoHash: "app",
},
},
@@ -849,7 +850,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
"metadata": map[string]any{
"name": "uid3",
"annotations": map[string]any{
- utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "orphaned",
+ utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("orphaned"),
utils.AnnoKeyRepoHash: "hash",
utils.AnnoKeyRepoPath: "path/to/file",
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
@@ -858,7 +859,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
"spec": map[string]any{},
}}, nil).Once()
k8sCliMock.On("Search", mock.Anything, int64(1), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
- return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == provisionedFileNameWithPrefix("test") && req.Options.Fields[0].Operator == "notin"
+ return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix("test") && req.Options.Fields[0].Operator == "notin"
})).Return(&resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
@@ -888,7 +889,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
}, nil).Once()
k8sCliMock.On("Search", mock.Anything, int64(2), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
- return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == provisionedFileNameWithPrefix("test") && req.Options.Fields[0].Operator == "notin"
+ return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix("test") && req.Options.Fields[0].Operator == "notin"
})).Return(&resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
@@ -959,7 +960,7 @@ func TestUnprovisionDashboard(t *testing.T) {
"metadata": map[string]any{
"name": "uid",
"annotations": map[string]any{
- utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "test",
+ utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"),
utils.AnnoKeyRepoHash: "hash",
utils.AnnoKeyRepoPath: "path/to/file",
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
@@ -1053,7 +1054,7 @@ func TestGetDashboardsByPluginID(t *testing.T) {
k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(uidUnstructured, nil)
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
- return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == "plugin" &&
+ return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == dashboard.PluginIDRepoName &&
req.Options.Fields[1].Key == "repo.path" && req.Options.Fields[1].Values[0] == "testing"
})).Return(&resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
@@ -1983,7 +1984,7 @@ func TestSearchProvisionedDashboardsThroughK8sRaw(t *testing.T) {
"metadata": map[string]any{
"name": "uid",
"annotations": map[string]any{
- utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "test",
+ utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"),
utils.AnnoKeyRepoHash: "hash",
utils.AnnoKeyRepoPath: "path/to/file",
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
diff --git a/pkg/services/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go
index eed18fda8db..508fece061c 100644
--- a/pkg/services/dashboards/store_mock.go
+++ b/pkg/services/dashboards/store_mock.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.42.2. DO NOT EDIT.
+// Code generated by mockery v2.52.2. DO NOT EDIT.
package dashboards
@@ -66,36 +66,6 @@ func (_m *FakeDashboardStore) Count(_a0 context.Context, _a1 *quota.ScopeParamet
return r0, r1
}
-// CountInOrg provides a mock function with given fields: _a0, _a1
-func (_m *FakeDashboardStore) CountInOrg(_a0 context.Context, _a1 int64) (int64, error) {
- ret := _m.Called(_a0, _a1)
-
- if len(ret) == 0 {
- panic("no return value specified for Count")
- }
-
- var r0 int64
- var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, int64) (int64, error)); ok {
- return rf(_a0, _a1)
- }
- if rf, ok := ret.Get(0).(func(context.Context, int64) int64); ok {
- r0 = rf(_a0, _a1)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(int64)
- }
- }
-
- if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
- r1 = rf(_a0, _a1)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
// CountDashboardsInFolders provides a mock function with given fields: ctx, request
func (_m *FakeDashboardStore) CountDashboardsInFolders(ctx context.Context, request *CountDashboardsInFolderRequest) (int64, error) {
ret := _m.Called(ctx, request)
@@ -124,6 +94,35 @@ func (_m *FakeDashboardStore) CountDashboardsInFolders(ctx context.Context, requ
return r0, r1
}
+// CountInOrg provides a mock function with given fields: ctx, orgID
+func (_m *FakeDashboardStore) CountInOrg(ctx context.Context, orgID int64) (int64, error) {
+ ret := _m.Called(ctx, orgID)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Count")
+ }
+
+ var r0 int64
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, int64) (int64, error)); ok {
+ return rf(ctx, orgID)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, int64) int64); ok {
+ r0 = rf(ctx, orgID)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(int64)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
+ r1 = rf(ctx, orgID)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
// DeleteAllDashboards provides a mock function with given fields: ctx, orgID
func (_m *FakeDashboardStore) DeleteAllDashboards(ctx context.Context, orgID int64) error {
ret := _m.Called(ctx, orgID)
@@ -436,6 +435,36 @@ func (_m *FakeDashboardStore) GetDashboardsByPluginID(ctx context.Context, query
return r0, r1
}
+// GetOrphanedProvisionedDashboards provides a mock function with given fields: ctx, notIn
+func (_m *FakeDashboardStore) GetOrphanedProvisionedDashboards(ctx context.Context, notIn []string) ([]*Dashboard, error) {
+ ret := _m.Called(ctx, notIn)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetOrphanedProvisionedDashboards")
+ }
+
+ var r0 []*Dashboard
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, []string) ([]*Dashboard, error)); ok {
+ return rf(ctx, notIn)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, []string) []*Dashboard); ok {
+ r0 = rf(ctx, notIn)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]*Dashboard)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, []string) error); ok {
+ r1 = rf(ctx, notIn)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
// GetProvisionedDashboardData provides a mock function with given fields: ctx, name
func (_m *FakeDashboardStore) GetProvisionedDashboardData(ctx context.Context, name string) ([]*DashboardProvisioning, error) {
ret := _m.Called(ctx, name)
@@ -466,6 +495,36 @@ func (_m *FakeDashboardStore) GetProvisionedDashboardData(ctx context.Context, n
return r0, r1
}
+// GetProvisionedDashboardsByName provides a mock function with given fields: ctx, name
+func (_m *FakeDashboardStore) GetProvisionedDashboardsByName(ctx context.Context, name string) ([]*Dashboard, error) {
+ ret := _m.Called(ctx, name)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetProvisionedDashboardsByName")
+ }
+
+ var r0 []*Dashboard
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, string) ([]*Dashboard, error)); ok {
+ return rf(ctx, name)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, string) []*Dashboard); ok {
+ r0 = rf(ctx, name)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]*Dashboard)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
+ r1 = rf(ctx, name)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
// GetProvisionedDataByDashboardID provides a mock function with given fields: ctx, dashboardID
func (_m *FakeDashboardStore) GetProvisionedDataByDashboardID(ctx context.Context, dashboardID int64) (*DashboardProvisioning, error) {
ret := _m.Called(ctx, dashboardID)
diff --git a/pkg/services/grpcserver/interceptors/logging.go b/pkg/services/grpcserver/interceptors/logging.go
index 2a3997a7024..db4f017d1e4 100644
--- a/pkg/services/grpcserver/interceptors/logging.go
+++ b/pkg/services/grpcserver/interceptors/logging.go
@@ -2,27 +2,34 @@ package interceptors
import (
"context"
+ "fmt"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
"google.golang.org/grpc"
)
-func LoggingUnaryInterceptor(logger log.Logger, enabled bool) grpc.UnaryServerInterceptor {
- return func(
- ctx context.Context,
- req any,
- info *grpc.UnaryServerInfo,
- handler grpc.UnaryHandler,
- ) (resp any, err error) {
- resp, err = handler(ctx, req)
- if enabled {
- ctxLogger := logger.FromContext(ctx)
- if err != nil {
- ctxLogger.Error("gRPC call", "method", info.FullMethod, "req", req, "err", err)
- } else {
- ctxLogger.Info("gRPC call", "method", info.FullMethod, "req", req, "resp", resp)
- }
+func InterceptorLogger(l log.Logger, enabled bool) logging.Logger {
+ return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) {
+ if !enabled {
+ return
}
- return resp, err
- }
+ l := l.FromContext(ctx)
+ switch lvl {
+ case logging.LevelDebug:
+ l.Debug(msg, fields...)
+ case logging.LevelInfo:
+ l.Info(msg, fields...)
+ case logging.LevelWarn:
+ l.Warn(msg, fields...)
+ case logging.LevelError:
+ l.Error(msg, fields...)
+ default:
+ panic(fmt.Sprintf("unknown level %v", lvl))
+ }
+ })
+}
+
+func LoggingUnaryInterceptor(logger log.Logger, enabled bool) grpc.UnaryServerInterceptor {
+ return logging.UnaryServerInterceptor(InterceptorLogger(logger, enabled))
}
diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go
index 306352fce84..d133c36e650 100644
--- a/pkg/services/libraryelements/database.go
+++ b/pkg/services/libraryelements/database.go
@@ -250,14 +250,17 @@ func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedIn
return err
}
- // then find the dashboards that were supposed to be connected to this element
- _, requester := identity.WithServiceIdentity(c, signedInUser.GetOrgID())
- dashs, err := l.dashboardsService.FindDashboards(c, &dashboards.FindPersistedDashboardsQuery{
+ // then find the dashboards that were supposed to be connected to this element.
+ // A identity may be able to delete a library element but not read all dashboards so we fetch then as the
+ // service user so we can prevent deletion of those connections
+ serviceCtx, serviceIdent := identity.WithServiceIdentity(c, signedInUser.GetOrgID())
+ dashs, err := l.dashboardsService.FindDashboards(serviceCtx, &dashboards.FindPersistedDashboardsQuery{
Type: searchstore.TypeDashboard,
- OrgId: signedInUser.GetOrgID(),
+ OrgId: serviceIdent.GetOrgID(),
DashboardIds: dashboardIDs,
- SignedInUser: requester, // a user may be able to delete a library element but not read all dashboards. We still need to run this check, so we don't allow deleting elements if dashboards are connected
+ SignedInUser: serviceIdent,
})
+
if err != nil {
return err
}
diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go
index e11c5eb1add..66be69d0079 100644
--- a/pkg/services/ngalert/models/alert_rule.go
+++ b/pkg/services/ngalert/models/alert_rule.go
@@ -295,7 +295,8 @@ type AlertRule struct {
}
type AlertRuleMetadata struct {
- EditorSettings EditorSettings `json:"editor_settings"`
+ EditorSettings EditorSettings `json:"editor_settings"`
+ PrometheusStyleRule *PrometheusStyleRule `json:"prometheus_style_rule,omitempty"`
}
type EditorSettings struct {
@@ -303,6 +304,10 @@ type EditorSettings struct {
SimplifiedNotificationsSection bool `json:"simplified_notifications_section"`
}
+type PrometheusStyleRule struct {
+ OriginalRuleDefinition string `json:"original_rule_definition,omitempty"`
+}
+
// Namespaced describes a class of resources that are stored in a specific namespace.
type Namespaced interface {
GetNamespaceUID() string
@@ -748,6 +753,11 @@ func (alertRule *AlertRule) Copy() *AlertRule {
}
}
+ if alertRule.Metadata.PrometheusStyleRule != nil {
+ prometheusStyleRule := *alertRule.Metadata.PrometheusStyleRule
+ result.Metadata.PrometheusStyleRule = &prometheusStyleRule
+ }
+
for _, s := range alertRule.NotificationSettings {
result.NotificationSettings = append(result.NotificationSettings, CopyNotificationSettings(s))
}
diff --git a/pkg/services/ngalert/models/alert_rule_test.go b/pkg/services/ngalert/models/alert_rule_test.go
index 6594732c04b..9f6c8b52b96 100644
--- a/pkg/services/ngalert/models/alert_rule_test.go
+++ b/pkg/services/ngalert/models/alert_rule_test.go
@@ -841,7 +841,7 @@ func TestDiff(t *testing.T) {
}
})
- t.Run("should detect changes in Metadata", func(t *testing.T) {
+ t.Run("should detect changes in Metadata.EditorSettings", func(t *testing.T) {
rule1 := RuleGen.With(RuleGen.WithMetadata(AlertRuleMetadata{EditorSettings: EditorSettings{
SimplifiedQueryAndExpressionsSection: false,
SimplifiedNotificationsSection: false,
@@ -858,6 +858,21 @@ func TestDiff(t *testing.T) {
"Metadata.EditorSettings.SimplifiedNotificationsSection",
}, diff.Paths())
})
+
+ t.Run("should detect changes in Metadata.PrometheusStyleRule", func(t *testing.T) {
+ rule1 := RuleGen.With(RuleGen.WithMetadata(AlertRuleMetadata{PrometheusStyleRule: &PrometheusStyleRule{
+ OriginalRuleDefinition: "data",
+ }})).GenerateRef()
+
+ rule2 := CopyRule(rule1, RuleGen.WithMetadata(AlertRuleMetadata{PrometheusStyleRule: &PrometheusStyleRule{
+ OriginalRuleDefinition: "updated data",
+ }}))
+
+ diff := rule1.Diff(rule2)
+ assert.ElementsMatch(t, []string{
+ "Metadata.PrometheusStyleRule.OriginalRuleDefinition",
+ }, diff.Paths())
+ })
}
func TestSortByGroupIndex(t *testing.T) {
@@ -940,11 +955,21 @@ func TestAlertRuleGetKeyWithGroup(t *testing.T) {
}
func TestAlertRuleCopy(t *testing.T) {
- for i := 0; i < 100; i++ {
- rule := RuleGen.GenerateRef()
+ t.Run("should return a copy of the rule", func(t *testing.T) {
+ for i := 0; i < 100; i++ {
+ rule := RuleGen.GenerateRef()
+ copied := rule.Copy()
+ require.Empty(t, rule.Diff(copied))
+ }
+ })
+
+ t.Run("should create a copy of the prometheus rule definition from the metadata", func(t *testing.T) {
+ rule := RuleGen.With(RuleGen.WithMetadata(AlertRuleMetadata{PrometheusStyleRule: &PrometheusStyleRule{
+ OriginalRuleDefinition: "data",
+ }})).GenerateRef()
copied := rule.Copy()
- require.Empty(t, rule.Diff(copied))
- }
+ require.NotSame(t, rule.Metadata.PrometheusStyleRule, copied.Metadata.PrometheusStyleRule)
+ })
}
// This test makes sure the default generator
diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go
new file mode 100644
index 00000000000..d14a37aa9f5
--- /dev/null
+++ b/pkg/services/ngalert/prom/convert.go
@@ -0,0 +1,220 @@
+package prom
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "gopkg.in/yaml.v3"
+
+ "github.com/grafana/grafana/pkg/services/datasources"
+ "github.com/grafana/grafana/pkg/services/ngalert/models"
+)
+
+type Config struct {
+ DatasourceUID string
+ DatasourceType string
+ FromTimeRange *time.Duration
+ EvaluationOffset *time.Duration
+ ExecErrState models.ExecutionErrorState
+ NoDataState models.NoDataState
+ RecordingRules RulesConfig
+ AlertRules RulesConfig
+}
+
+type RulesConfig struct {
+ IsPaused bool
+}
+
+var (
+ defaultTimeRange = 600 * time.Second
+ defaultEvaluationOffset = 0 * time.Minute
+
+ defaultConfig = Config{
+ FromTimeRange: &defaultTimeRange,
+ EvaluationOffset: &defaultEvaluationOffset,
+ ExecErrState: models.ErrorErrState,
+ NoDataState: models.NoData,
+ }
+)
+
+type Converter struct {
+ cfg Config
+}
+
+func NewConverter(cfg Config) (*Converter, error) {
+ if cfg.DatasourceUID == "" {
+ return nil, fmt.Errorf("datasource UID is required")
+ }
+ if cfg.DatasourceType == "" {
+ return nil, fmt.Errorf("datasource type is required")
+ }
+ if cfg.FromTimeRange == nil {
+ cfg.FromTimeRange = defaultConfig.FromTimeRange
+ }
+ if cfg.EvaluationOffset == nil {
+ cfg.EvaluationOffset = defaultConfig.EvaluationOffset
+ }
+ if cfg.ExecErrState == "" {
+ cfg.ExecErrState = defaultConfig.ExecErrState
+ }
+ if cfg.NoDataState == "" {
+ cfg.NoDataState = defaultConfig.NoDataState
+ }
+
+ if cfg.DatasourceType != datasources.DS_PROMETHEUS && cfg.DatasourceType != datasources.DS_LOKI {
+ return nil, fmt.Errorf("invalid datasource type: %s", cfg.DatasourceType)
+ }
+
+ return &Converter{
+ cfg: cfg,
+ }, nil
+}
+
+// PrometheusRulesToGrafana converts a Prometheus rule group into Grafana Alerting rule group.
+func (p *Converter) PrometheusRulesToGrafana(orgID int64, namespaceUID string, group PrometheusRuleGroup) (*models.AlertRuleGroup, error) {
+ for _, rule := range group.Rules {
+ err := validatePrometheusRule(rule)
+ if err != nil {
+ return nil, fmt.Errorf("invalid Prometheus rule '%s': %w", rule.Alert, err)
+ }
+ }
+
+ grafanaGroup, err := p.convertRuleGroup(orgID, namespaceUID, group)
+ if err != nil {
+ return nil, fmt.Errorf("failed to convert rule group '%s': %w", group.Name, err)
+ }
+
+ return grafanaGroup, nil
+}
+
+func validatePrometheusRule(rule PrometheusRule) error {
+ if rule.KeepFiringFor != nil {
+ return fmt.Errorf("keep_firing_for is not supported")
+ }
+
+ return nil
+}
+
+func (p *Converter) convertRuleGroup(orgID int64, namespaceUID string, promGroup PrometheusRuleGroup) (*models.AlertRuleGroup, error) {
+ uniqueNames := map[string]int{}
+ rules := make([]models.AlertRule, 0, len(promGroup.Rules))
+ interval := time.Duration(promGroup.Interval)
+ for i, rule := range promGroup.Rules {
+ gr, err := p.convertRule(orgID, namespaceUID, promGroup.Name, rule)
+ if err != nil {
+ return nil, fmt.Errorf("failed to convert Prometheus rule '%s' to Grafana rule: %w", rule.Alert, err)
+ }
+ gr.RuleGroupIndex = i + 1
+ gr.IntervalSeconds = int64(interval.Seconds())
+
+ // Check rule title uniqueness within the group.
+ uniqueNames[gr.Title]++
+ if val := uniqueNames[gr.Title]; val > 1 {
+ gr.Title = fmt.Sprintf("%s (%d)", gr.Title, val)
+ }
+
+ rules = append(rules, gr)
+ }
+
+ result := &models.AlertRuleGroup{
+ FolderUID: namespaceUID,
+ Interval: int64(interval.Seconds()),
+ Rules: rules,
+ Title: promGroup.Name,
+ }
+
+ return result, nil
+}
+
+func (p *Converter) convertRule(orgID int64, namespaceUID, group string, rule PrometheusRule) (models.AlertRule, error) {
+ var forInterval time.Duration
+ if rule.For != nil {
+ forInterval = time.Duration(*rule.For)
+ }
+
+ queryNode, err := createAlertQueryNode(p.cfg.DatasourceUID, p.cfg.DatasourceType, rule.Expr, *p.cfg.FromTimeRange, *p.cfg.EvaluationOffset)
+ if err != nil {
+ return models.AlertRule{}, err
+ }
+
+ var title string
+ if rule.Record != "" {
+ title = rule.Record
+ } else {
+ title = rule.Alert
+ }
+
+ labels := make(map[string]string, len(rule.Labels)+1)
+ for k, v := range rule.Labels {
+ labels[k] = v
+ }
+
+ originalRuleDefinition, err := yaml.Marshal(rule)
+ if err != nil {
+ return models.AlertRule{}, fmt.Errorf("failed to marshal original rule definition: %w", err)
+ }
+
+ result := models.AlertRule{
+ OrgID: orgID,
+ NamespaceUID: namespaceUID,
+ Title: title,
+ Data: []models.AlertQuery{queryNode},
+ Condition: "A",
+ NoDataState: p.cfg.NoDataState,
+ ExecErrState: p.cfg.ExecErrState,
+ Annotations: rule.Annotations,
+ Labels: labels,
+ For: forInterval,
+ RuleGroup: group,
+ Metadata: models.AlertRuleMetadata{
+ PrometheusStyleRule: &models.PrometheusStyleRule{
+ OriginalRuleDefinition: string(originalRuleDefinition),
+ },
+ },
+ }
+
+ if rule.Record != "" {
+ result.Record = &models.Record{
+ From: "A",
+ Metric: rule.Record,
+ }
+ result.IsPaused = p.cfg.RecordingRules.IsPaused
+ } else {
+ result.IsPaused = p.cfg.AlertRules.IsPaused
+ }
+
+ return result, nil
+}
+
+func createAlertQueryNode(datasourceUID, datasourceType, expr string, fromTimeRange, evaluationOffset time.Duration) (models.AlertQuery, error) {
+ modelData := map[string]interface{}{
+ "datasource": map[string]interface{}{
+ "type": datasourceType,
+ "uid": datasourceUID,
+ },
+ "expr": expr,
+ "instant": true,
+ "range": false,
+ "refId": "A",
+ }
+
+ if datasourceType == datasources.DS_LOKI {
+ modelData["queryType"] = "instant"
+ }
+
+ modelJSON, err := json.Marshal(modelData)
+ if err != nil {
+ return models.AlertQuery{}, err
+ }
+
+ return models.AlertQuery{
+ DatasourceUID: datasourceUID,
+ Model: modelJSON,
+ RefID: "A",
+ RelativeTimeRange: models.RelativeTimeRange{
+ From: models.Duration(fromTimeRange + evaluationOffset),
+ To: models.Duration(0 + evaluationOffset),
+ },
+ }, nil
+}
diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go
new file mode 100644
index 00000000000..f175686fd3d
--- /dev/null
+++ b/pkg/services/ngalert/prom/convert_test.go
@@ -0,0 +1,192 @@
+package prom
+
+import (
+ "testing"
+ "time"
+
+ prommodel "github.com/prometheus/common/model"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+
+ "github.com/grafana/grafana/pkg/services/datasources"
+ "github.com/grafana/grafana/pkg/services/ngalert/models"
+)
+
+func TestPrometheusRulesToGrafana(t *testing.T) {
+ fiveMin := prommodel.Duration(5 * time.Minute)
+
+ testCases := []struct {
+ name string
+ orgID int64
+ namespace string
+ promGroup PrometheusRuleGroup
+ config Config
+ expectError bool
+ }{
+ {
+ name: "valid rule group",
+ orgID: 1,
+ namespace: "some-namespace-uid",
+ promGroup: PrometheusRuleGroup{
+ Name: "test-group-1",
+ Interval: prommodel.Duration(10 * time.Second),
+ Rules: []PrometheusRule{
+ {
+ Alert: "alert-1",
+ Expr: "cpu_usage > 80",
+ For: &fiveMin,
+ Labels: map[string]string{
+ "severity": "critical",
+ },
+ Annotations: map[string]string{
+ "summary": "CPU usage is critical",
+ },
+ },
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "rules with keep_firing_for are not supported",
+ orgID: 1,
+ namespace: "namespaceUID",
+ promGroup: PrometheusRuleGroup{
+ Name: "test-group-1",
+ Interval: prommodel.Duration(1 * time.Minute),
+ Rules: []PrometheusRule{
+ {
+ Alert: "alert-1",
+ Expr: "up == 0",
+ KeepFiringFor: &fiveMin,
+ },
+ },
+ },
+ expectError: true,
+ },
+ {
+ name: "rule with empty interval",
+ orgID: 1,
+ namespace: "namespaceUID",
+ promGroup: PrometheusRuleGroup{
+ Name: "test-group-1",
+ Rules: []PrometheusRule{
+ {
+ Alert: "alert-1",
+ Expr: "up == 0",
+ },
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "recording rule",
+ orgID: 1,
+ namespace: "namespaceUID",
+ promGroup: PrometheusRuleGroup{
+ Name: "test-group-1",
+ Rules: []PrometheusRule{
+ {
+ Record: "some_metric",
+ Expr: "sum(rate(http_requests_total[5m]))",
+ },
+ },
+ },
+ expectError: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ tc.config.DatasourceUID = "datasource-uid"
+ tc.config.DatasourceType = datasources.DS_PROMETHEUS
+ converter, err := NewConverter(tc.config)
+ require.NoError(t, err)
+
+ grafanaGroup, err := converter.PrometheusRulesToGrafana(tc.orgID, tc.namespace, tc.promGroup)
+
+ if tc.expectError {
+ require.Error(t, err, tc.name)
+ return
+ }
+ require.NoError(t, err, tc.name)
+
+ require.Equal(t, tc.promGroup.Name, grafanaGroup.Title, tc.name)
+ expectedInterval := int64(time.Duration(tc.promGroup.Interval).Seconds())
+ require.Equal(t, expectedInterval, grafanaGroup.Interval, tc.name)
+
+ require.Equal(t, len(tc.promGroup.Rules), len(grafanaGroup.Rules), tc.name)
+
+ for j, promRule := range tc.promGroup.Rules {
+ grafanaRule := grafanaGroup.Rules[j]
+
+ if promRule.Record != "" {
+ require.Equal(t, promRule.Record, grafanaRule.Title)
+ } else {
+ require.Equal(t, promRule.Alert, grafanaRule.Title)
+ }
+
+ var expectedFor time.Duration
+ if promRule.For != nil {
+ expectedFor = time.Duration(*promRule.For)
+ }
+ require.Equal(t, expectedFor, grafanaRule.For, tc.name)
+
+ expectedLabels := make(map[string]string, len(promRule.Labels)+1)
+ for k, v := range promRule.Labels {
+ expectedLabels[k] = v
+ }
+
+ require.Equal(t, expectedLabels, grafanaRule.Labels, tc.name)
+ require.Equal(t, promRule.Annotations, grafanaRule.Annotations, tc.name)
+ require.Equal(t, models.Duration(0*time.Minute), grafanaRule.Data[0].RelativeTimeRange.To)
+ require.Equal(t, models.Duration(10*time.Minute), grafanaRule.Data[0].RelativeTimeRange.From)
+
+ originalRuleDefinition, err := yaml.Marshal(promRule)
+ require.NoError(t, err)
+ require.Equal(t, string(originalRuleDefinition), grafanaRule.Metadata.PrometheusStyleRule.OriginalRuleDefinition)
+ }
+ })
+ }
+}
+
+func TestPrometheusRulesToGrafanaWithDuplicateRuleNames(t *testing.T) {
+ cfg := Config{
+ DatasourceUID: "datasource-uid",
+ DatasourceType: datasources.DS_PROMETHEUS,
+ }
+ converter, err := NewConverter(cfg)
+ require.NoError(t, err)
+
+ promGroup := PrometheusRuleGroup{
+ Name: "test-group-1",
+ Interval: prommodel.Duration(10 * time.Second),
+ Rules: []PrometheusRule{
+ {
+ Alert: "alert",
+ Expr: "up",
+ },
+ {
+ Alert: "alert",
+ Expr: "up",
+ },
+ {
+ Alert: "another alert",
+ Expr: "up",
+ },
+ {
+ Alert: "alert",
+ Expr: "up",
+ },
+ },
+ }
+
+ group, err := converter.PrometheusRulesToGrafana(1, "namespaceUID", promGroup)
+ require.NoError(t, err)
+
+ require.Equal(t, "test-group-1", group.Title)
+ require.Len(t, group.Rules, 4)
+ require.Equal(t, "alert", group.Rules[0].Title)
+ require.Equal(t, "alert (2)", group.Rules[1].Title)
+ require.Equal(t, "another alert", group.Rules[2].Title)
+ require.Equal(t, "alert (3)", group.Rules[3].Title)
+}
diff --git a/pkg/services/ngalert/prom/models.go b/pkg/services/ngalert/prom/models.go
new file mode 100644
index 00000000000..f7e8bbfc95b
--- /dev/null
+++ b/pkg/services/ngalert/prom/models.go
@@ -0,0 +1,25 @@
+package prom
+
+import (
+ prommodel "github.com/prometheus/common/model"
+)
+
+type PrometheusRulesFile struct {
+ Groups []PrometheusRuleGroup `yaml:"groups"`
+}
+
+type PrometheusRuleGroup struct {
+ Name string `yaml:"name"`
+ Interval prommodel.Duration `yaml:"interval"`
+ Rules []PrometheusRule `yaml:"rules"`
+}
+
+type PrometheusRule struct {
+ Alert string `yaml:"alert,omitempty"`
+ Expr string `yaml:"expr,omitempty"`
+ For *prommodel.Duration `yaml:"for,omitempty"`
+ KeepFiringFor *prommodel.Duration `yaml:"keep_firing_for,omitempty"`
+ Labels map[string]string `yaml:"labels,omitempty"`
+ Annotations map[string]string `yaml:"annotations,omitempty"`
+ Record string `yaml:"record,omitempty"`
+}
diff --git a/pkg/services/ngalert/prom/models_test.go b/pkg/services/ngalert/prom/models_test.go
new file mode 100644
index 00000000000..fbf7d65a847
--- /dev/null
+++ b/pkg/services/ngalert/prom/models_test.go
@@ -0,0 +1,106 @@
+package prom
+
+import (
+ "testing"
+ "time"
+
+ prommodel "github.com/prometheus/common/model"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+func TestPrometheusRulesFileYAML(t *testing.T) {
+ interval := prommodel.Duration(5 * time.Minute)
+ alertFor := prommodel.Duration(10 * time.Minute)
+ keepFiring := prommodel.Duration(15 * time.Minute)
+
+ tests := []struct {
+ name string
+ input PrometheusRulesFile
+ expectedYAML string
+ }{
+ {
+ name: "simple alert rule and a recording rule",
+ input: PrometheusRulesFile{
+ Groups: []PrometheusRuleGroup{
+ {
+ Name: "test_group",
+ Interval: interval,
+ Rules: []PrometheusRule{
+ {
+ Alert: "alert-1",
+ Expr: "vector(0) > 90",
+ For: &alertFor,
+ KeepFiringFor: &keepFiring,
+ Labels: map[string]string{
+ "team": "alerting",
+ },
+ Annotations: map[string]string{
+ "summary": "some summary",
+ "description": "some description",
+ },
+ },
+ {
+ Record: "vector(1)",
+ },
+ },
+ },
+ },
+ },
+ expectedYAML: `
+groups:
+ - name: test_group
+ interval: 5m
+ rules:
+ - alert: alert-1
+ expr: vector(0) > 90
+ for: 10m
+ keep_firing_for: 15m
+ labels:
+ team: alerting
+ annotations:
+ description: some description
+ summary: some summary
+ - record: vector(1)
+`,
+ },
+ {
+ name: "empty rules file",
+ input: PrometheusRulesFile{
+ Groups: []PrometheusRuleGroup{},
+ },
+ expectedYAML: `groups: []`,
+ },
+ {
+ name: "empty group",
+ input: PrometheusRulesFile{
+ Groups: []PrometheusRuleGroup{
+ {
+ Name: "empty_group",
+ Interval: interval,
+ Rules: []PrometheusRule{},
+ },
+ },
+ },
+ expectedYAML: `
+groups:
+ - name: empty_group
+ interval: 5m
+ rules: []`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ yamlData, err := yaml.Marshal(tt.input)
+ require.NoError(t, err, "Failed to marshal to YAML")
+ require.YAMLEq(t, tt.expectedYAML, string(yamlData))
+
+ var parsed PrometheusRulesFile
+ err = yaml.Unmarshal(yamlData, &parsed)
+ require.NoError(t, err, "Failed to unmarshal from YAML")
+
+ require.Equal(t, tt.input, parsed)
+ })
+ }
+}
diff --git a/pkg/services/ngalert/state/cache.go b/pkg/services/ngalert/state/cache.go
index 1abeff1c2a9..a7302fc5518 100644
--- a/pkg/services/ngalert/state/cache.go
+++ b/pkg/services/ngalert/state/cache.go
@@ -143,81 +143,6 @@ func expandAnnotationsAndLabels(ctx context.Context, log log.Logger, alertRule *
return lbs, annotations
}
-func (c *cache) create(ctx context.Context, log log.Logger, alertRule *ngModels.AlertRule, result eval.Result, extraLabels data.Labels, externalURL *url.URL) *State {
- lbs, annotations := expandAnnotationsAndLabels(ctx, log, alertRule, result, extraLabels, externalURL)
-
- cacheID := lbs.Fingerprint()
- // For new states, we set StartsAt & EndsAt to EvaluatedAt as this is the
- // expected value for a Normal state during state transition.
- newState := State{
- OrgID: alertRule.OrgID,
- AlertRuleUID: alertRule.UID,
- CacheID: cacheID,
- State: eval.Normal,
- StateReason: "",
- ResultFingerprint: result.Instance.Fingerprint(), // remember original result fingerprint
- LatestResult: nil,
- Error: nil,
- Image: nil,
- Annotations: annotations,
- Labels: lbs,
- Values: nil,
- StartsAt: result.EvaluatedAt,
- EndsAt: result.EvaluatedAt,
- ResolvedAt: nil,
- LastSentAt: nil,
- LastEvaluationString: "",
- LastEvaluationTime: result.EvaluatedAt,
- EvaluationDuration: result.EvaluationDuration,
- }
-
- existingState := c.get(alertRule.OrgID, alertRule.UID, cacheID)
- if existingState == nil {
- return &newState
- }
- // if there is existing state, copy over the current values that may be needed to determine the final state.
- // TODO remove some unnecessary assignments below because they are overridden in setNextState
- newState.State = existingState.State
- newState.StateReason = existingState.StateReason
- newState.Image = existingState.Image
- newState.LatestResult = existingState.LatestResult
- newState.Error = existingState.Error
- newState.Values = existingState.Values
- newState.LastEvaluationString = existingState.LastEvaluationString
- newState.StartsAt = existingState.StartsAt
- newState.EndsAt = existingState.EndsAt
- newState.ResolvedAt = existingState.ResolvedAt
- newState.LastSentAt = existingState.LastSentAt
- // Annotations can change over time, however we also want to maintain
- // certain annotations across evaluations
- for key := range ngModels.InternalAnnotationNameSet { // Changing in
- value, ok := existingState.Annotations[key]
- if !ok {
- continue
- }
- // If the annotation is not present then it should be copied from
- // the current state to the new state
- if _, ok = newState.Annotations[key]; !ok {
- newState.Annotations[key] = value
- }
- }
-
- // if the current state is "data source error" then it may have additional labels that may not exist in the new state.
- // See https://github.com/grafana/grafana/blob/c7fdf8ce706c2c9d438f5e6eabd6e580bac4946b/pkg/services/ngalert/state/state.go#L161-L163
- // copy known labels over to the new instance, it can help reduce flapping
- // TODO fix this?
- if existingState.State == eval.Error && result.State == eval.Error {
- setIfExist := func(lbl string) {
- if v, ok := existingState.Labels[lbl]; ok {
- newState.Labels[lbl] = v
- }
- }
- setIfExist("datasource_uid")
- setIfExist("ref_id")
- }
- return &newState
-}
-
// expand returns the expanded templates of all annotations or labels for the template data.
// If a template cannot be expanded due to an error in the template the original template is
// maintained and an error is added to the multierror. All errors in the multierror are
diff --git a/pkg/services/ngalert/state/cache_test.go b/pkg/services/ngalert/state/cache_test.go
index 9fe50fdb120..6defc25086d 100644
--- a/pkg/services/ngalert/state/cache_test.go
+++ b/pkg/services/ngalert/state/cache_test.go
@@ -3,15 +3,11 @@ package state
import (
"context"
"errors"
- "fmt"
"math/rand"
- "net/url"
"testing"
"time"
- "github.com/google/uuid"
"github.com/grafana/grafana-plugin-sdk-go/data"
- "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
@@ -118,313 +114,6 @@ func Test_expand(t *testing.T) {
})
}
-func Test_create(t *testing.T) {
- url := &url.URL{
- Scheme: "http",
- Host: "localhost:3000",
- Path: "/test",
- }
- l := log.New("test")
- c := newCache()
-
- gen := models.RuleGen
- generateRule := gen.With(gen.WithNotEmptyLabels(5, "rule-")).GenerateRef
-
- t.Run("should combine all labels", func(t *testing.T) {
- rule := generateRule()
-
- extraLabels := models.GenerateAlertLabels(5, "extra-")
- result := eval.Result{
- Instance: models.GenerateAlertLabels(5, "result-"),
- }
- state := c.create(context.Background(), l, rule, result, extraLabels, url)
- for key, expected := range extraLabels {
- require.Equal(t, expected, state.Labels[key])
- }
- assert.Len(t, state.Labels, len(extraLabels)+len(rule.Labels)+len(result.Instance))
- for key, expected := range extraLabels {
- assert.Equal(t, expected, state.Labels[key])
- }
- for key, expected := range rule.Labels {
- assert.Equal(t, expected, state.Labels[key])
- }
- for key, expected := range result.Instance {
- assert.Equal(t, expected, state.Labels[key])
- }
- })
- t.Run("extra labels should take precedence over rule and result labels", func(t *testing.T) {
- rule := generateRule()
-
- extraLabels := models.GenerateAlertLabels(2, "extra-")
-
- result := eval.Result{
- Instance: models.GenerateAlertLabels(5, "result-"),
- }
- for key := range extraLabels {
- rule.Labels[key] = "rule-" + util.GenerateShortUID()
- result.Instance[key] = "result-" + util.GenerateShortUID()
- }
-
- state := c.create(context.Background(), l, rule, result, extraLabels, url)
- for key, expected := range extraLabels {
- require.Equal(t, expected, state.Labels[key])
- }
- })
- t.Run("rule labels should take precedence over result labels", func(t *testing.T) {
- rule := generateRule()
-
- extraLabels := models.GenerateAlertLabels(2, "extra-")
-
- result := eval.Result{
- Instance: models.GenerateAlertLabels(5, "result-"),
- }
- for key := range rule.Labels {
- result.Instance[key] = "result-" + util.GenerateShortUID()
- }
- state := c.create(context.Background(), l, rule, result, extraLabels, url)
- for key, expected := range rule.Labels {
- require.Equal(t, expected, state.Labels[key])
- }
- })
- t.Run("rule labels should be able to be expanded with result and extra labels", func(t *testing.T) {
- result := eval.Result{
- Instance: models.GenerateAlertLabels(5, "result-"),
- }
- rule := generateRule()
-
- extraLabels := models.GenerateAlertLabels(2, "extra-")
-
- labelTemplates := make(data.Labels)
- for key := range extraLabels {
- labelTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
- }
- for key := range result.Instance {
- labelTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
- }
- rule.Labels = labelTemplates
-
- state := c.create(context.Background(), l, rule, result, extraLabels, url)
- for key, expected := range extraLabels {
- assert.Equal(t, expected, state.Labels["rule-"+key])
- }
- for key, expected := range result.Instance {
- assert.Equal(t, expected, state.Labels["rule-"+key])
- }
- })
- t.Run("rule annotations should be able to be expanded with result and extra labels", func(t *testing.T) {
- result := eval.Result{
- Instance: models.GenerateAlertLabels(5, "result-"),
- }
-
- rule := generateRule()
-
- extraLabels := models.GenerateAlertLabels(2, "extra-")
-
- annotationTemplates := make(data.Labels)
- for key := range extraLabels {
- annotationTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
- }
- for key := range result.Instance {
- annotationTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
- }
- rule.Annotations = annotationTemplates
-
- state := c.create(context.Background(), l, rule, result, extraLabels, url)
- for key, expected := range extraLabels {
- assert.Equal(t, expected, state.Annotations["rule-"+key])
- }
- for key, expected := range result.Instance {
- assert.Equal(t, expected, state.Annotations["rule-"+key])
- }
- })
- t.Run("when result labels collide with system labels from LabelsUserCannotSpecify", func(t *testing.T) {
- result := eval.Result{
- Instance: models.GenerateAlertLabels(5, "result-"),
- }
- m := models.LabelsUserCannotSpecify
- t.Cleanup(func() {
- models.LabelsUserCannotSpecify = m
- })
-
- models.LabelsUserCannotSpecify = map[string]struct{}{
- "__label1__": {},
- "label2__": {},
- "__label3": {},
- "label4": {},
- }
- result.Instance["__label1__"] = uuid.NewString()
- result.Instance["label2__"] = uuid.NewString()
- result.Instance["__label3"] = uuid.NewString()
- result.Instance["label4"] = uuid.NewString()
-
- rule := generateRule()
-
- state := c.create(context.Background(), l, rule, result, nil, url)
-
- for key := range models.LabelsUserCannotSpecify {
- assert.NotContains(t, state.Labels, key)
- }
- assert.Contains(t, state.Labels, "label1")
- assert.Equal(t, state.Labels["label1"], result.Instance["__label1__"])
-
- assert.Contains(t, state.Labels, "label2")
- assert.Equal(t, state.Labels["label2"], result.Instance["label2__"])
-
- assert.Contains(t, state.Labels, "label3")
- assert.Equal(t, state.Labels["label3"], result.Instance["__label3"])
-
- assert.Contains(t, state.Labels, "label4_user")
- assert.Equal(t, state.Labels["label4_user"], result.Instance["label4"])
-
- t.Run("should drop label if renamed collides with existing", func(t *testing.T) {
- result.Instance["label1"] = uuid.NewString()
- result.Instance["label1_user"] = uuid.NewString()
- result.Instance["label4_user"] = uuid.NewString()
-
- state = c.create(context.Background(), l, rule, result, nil, url)
- assert.NotContains(t, state.Labels, "__label1__")
- assert.Contains(t, state.Labels, "label1")
- assert.Equal(t, state.Labels["label1"], result.Instance["label1"])
- assert.Equal(t, state.Labels["label1_user"], result.Instance["label1_user"])
-
- assert.NotContains(t, state.Labels, "label4")
- assert.Equal(t, state.Labels["label4_user"], result.Instance["label4_user"])
- })
- })
-
- t.Run("creates a state with preset fields if there is no current state", func(t *testing.T) {
- rule := generateRule()
-
- extraLabels := models.GenerateAlertLabels(2, "extra-")
-
- result := eval.Result{
- Instance: models.GenerateAlertLabels(5, "result-"),
- }
-
- expectedLbl, expectedAnn := expandAnnotationsAndLabels(context.Background(), l, rule, result, extraLabels, url)
-
- state := c.create(context.Background(), l, rule, result, extraLabels, url)
-
- assert.Equal(t, rule.OrgID, state.OrgID)
- assert.Equal(t, rule.UID, state.AlertRuleUID)
- assert.Equal(t, state.Labels.Fingerprint(), state.CacheID)
- assert.Equal(t, result.State, state.State)
- assert.Equal(t, "", state.StateReason)
- assert.Equal(t, result.Instance.Fingerprint(), state.ResultFingerprint)
- assert.Nil(t, state.LatestResult)
- assert.Nil(t, state.Error)
- assert.Nil(t, state.Image)
- assert.EqualValues(t, expectedAnn, state.Annotations)
- assert.EqualValues(t, expectedLbl, state.Labels)
- assert.Nil(t, state.Values)
- assert.Equal(t, result.EvaluatedAt, state.StartsAt)
- assert.Equal(t, result.EvaluatedAt, state.EndsAt)
- assert.Nil(t, state.ResolvedAt)
- assert.Nil(t, state.LastSentAt)
- assert.Equal(t, "", state.LastEvaluationString)
- assert.Equal(t, result.EvaluatedAt, state.LastEvaluationTime)
- assert.Equal(t, result.EvaluationDuration, state.EvaluationDuration)
- })
-
- t.Run("it populates some fields from the current state if it exists", func(t *testing.T) {
- rule := generateRule()
-
- extraLabels := models.GenerateAlertLabels(2, "extra-")
-
- result := eval.Result{
- Instance: models.GenerateAlertLabels(5, "result-"),
- }
-
- expectedLbl, expectedAnn := expandAnnotationsAndLabels(context.Background(), l, rule, result, extraLabels, url)
-
- current := randomSate(rule.GetKey())
- current.CacheID = expectedLbl.Fingerprint()
-
- c.set(¤t)
-
- state := c.create(context.Background(), l, rule, result, extraLabels, url)
-
- assert.Equal(t, rule.OrgID, state.OrgID)
- assert.Equal(t, rule.UID, state.AlertRuleUID)
- assert.Equal(t, state.Labels.Fingerprint(), state.CacheID)
- assert.Equal(t, result.Instance.Fingerprint(), state.ResultFingerprint)
- assert.EqualValues(t, expectedAnn, state.Annotations)
- assert.EqualValues(t, expectedLbl, state.Labels)
- assert.Equal(t, result.EvaluatedAt, state.LastEvaluationTime)
- assert.Equal(t, result.EvaluationDuration, state.EvaluationDuration)
-
- assert.Equal(t, current.State, state.State)
- assert.Equal(t, current.StateReason, state.StateReason)
- assert.Equal(t, current.Image, state.Image)
- assert.Equal(t, current.LatestResult, state.LatestResult)
- assert.Equal(t, current.Error, state.Error)
- assert.Equal(t, current.Values, state.Values)
- assert.Equal(t, current.StartsAt, state.StartsAt)
- assert.Equal(t, current.EndsAt, state.EndsAt)
- assert.Equal(t, current.ResolvedAt, state.ResolvedAt)
- assert.Equal(t, current.LastSentAt, state.LastSentAt)
- assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString)
-
- t.Run("if result Error and current state is Error it should copy datasource_uid and ref_id labels", func(t *testing.T) {
- current = randomSate(rule.GetKey())
- current.CacheID = expectedLbl.Fingerprint()
- current.State = eval.Error
- current.Labels["datasource_uid"] = util.GenerateShortUID()
- current.Labels["ref_id"] = util.GenerateShortUID()
-
- c.set(¤t)
-
- result.State = eval.Error
- state = c.create(context.Background(), l, rule, result, extraLabels, url)
-
- l := expectedLbl.Copy()
- l["datasource_uid"] = current.Labels["datasource_uid"]
- l["ref_id"] = current.Labels["ref_id"]
-
- assert.Equal(t, current.CacheID, state.CacheID)
- assert.EqualValues(t, l, state.Labels)
-
- assert.Equal(t, rule.OrgID, state.OrgID)
- assert.Equal(t, rule.UID, state.AlertRuleUID)
-
- assert.Equal(t, result.Instance.Fingerprint(), state.ResultFingerprint)
- assert.EqualValues(t, expectedAnn, state.Annotations)
- assert.Equal(t, result.EvaluatedAt, state.LastEvaluationTime)
- assert.Equal(t, result.EvaluationDuration, state.EvaluationDuration)
-
- assert.Equal(t, current.State, state.State)
- assert.Equal(t, current.StateReason, state.StateReason)
- assert.Equal(t, current.Image, state.Image)
- assert.Equal(t, current.LatestResult, state.LatestResult)
- assert.Equal(t, current.Error, state.Error)
- assert.Equal(t, current.Values, state.Values)
- assert.Equal(t, current.StartsAt, state.StartsAt)
- assert.Equal(t, current.EndsAt, state.EndsAt)
- assert.Equal(t, current.ResolvedAt, state.ResolvedAt)
- assert.Equal(t, current.LastSentAt, state.LastSentAt)
- assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString)
- })
- t.Run("copies system-owned annotations from current state", func(t *testing.T) {
- current = randomSate(rule.GetKey())
- current.CacheID = expectedLbl.Fingerprint()
- current.State = eval.Error
- for key := range models.InternalAnnotationNameSet {
- current.Annotations[key] = util.GenerateShortUID()
- }
- c.set(¤t)
-
- result.State = eval.Error
- state = c.create(context.Background(), l, rule, result, extraLabels, url)
- ann := expectedAnn.Copy()
- for key := range models.InternalAnnotationNameSet {
- ann[key] = current.Annotations[key]
- }
- assert.EqualValues(t, expectedLbl, state.Labels)
- assert.EqualValues(t, ann, state.Annotations)
- })
- })
-}
-
func Test_mergeLabels(t *testing.T) {
t.Run("merges two maps", func(t *testing.T) {
a := models.GenerateAlertLabels(5, "set1-")
diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go
index cc22a65f517..f66002bbb3b 100644
--- a/pkg/services/ngalert/state/manager.go
+++ b/pkg/services/ngalert/state/manager.go
@@ -444,9 +444,16 @@ func (st *Manager) setNextStateForRule(ctx context.Context, alertRule *ngModels.
}
transitions := make([]StateTransition, 0, len(results))
for _, result := range results {
- currentState := st.cache.create(ctx, logger, alertRule, result, extraLabels, st.externalURL)
- s := st.setNextState(alertRule, currentState, result, nil, logger, takeImageFn)
- st.cache.set(currentState) // replace the existing state with the new one
+ newState := newState(ctx, logger, alertRule, result, extraLabels, st.externalURL)
+ if curState := st.cache.get(alertRule.OrgID, alertRule.UID, newState.CacheID); curState != nil {
+ patch(newState, curState, result)
+ }
+ start := st.clock.Now()
+ s := newState.transition(alertRule, result, nil, logger, takeImageFn)
+ if st.metrics != nil {
+ st.metrics.StateUpdateDuration.Observe(st.clock.Now().Sub(start).Seconds())
+ }
+ st.cache.set(newState) // replace the existing state with the new one
transitions = append(transitions, s)
}
return transitions
@@ -459,8 +466,12 @@ func (st *Manager) setNextStateForAll(alertRule *ngModels.AlertRule, result eval
states: make(map[data.Fingerprint]*State, len(currentStates)),
}
for _, currentState := range currentStates {
+ start := st.clock.Now()
newState := currentState.Copy()
- t := st.setNextState(alertRule, newState, result, extraAnnotations, logger, takeImageFn)
+ t := newState.transition(alertRule, result, extraAnnotations, logger, takeImageFn)
+ if st.metrics != nil {
+ st.metrics.StateUpdateDuration.Observe(st.clock.Now().Sub(start).Seconds())
+ }
updated.states[newState.CacheID] = newState
transitions = append(transitions, t)
}
@@ -468,115 +479,6 @@ func (st *Manager) setNextStateForAll(alertRule *ngModels.AlertRule, result eval
return transitions
}
-// Set the current state based on evaluation results
-func (st *Manager) setNextState(alertRule *ngModels.AlertRule, currentState *State, result eval.Result, extraAnnotations data.Labels, logger log.Logger, takeImageFn takeImageFn) StateTransition {
- start := st.clock.Now()
-
- currentState.LastEvaluationTime = result.EvaluatedAt
- currentState.EvaluationDuration = result.EvaluationDuration
- currentState.SetNextValues(result)
- currentState.LatestResult = &Evaluation{
- EvaluationTime: result.EvaluatedAt,
- EvaluationState: result.State,
- Values: currentState.Values,
- Condition: alertRule.Condition,
- }
- currentState.LastEvaluationString = result.EvaluationString
- oldState := currentState.State
- oldReason := currentState.StateReason
-
- // Add the instance to the log context to help correlate log lines for a state
- logger = logger.New("instance", result.Instance)
-
- // if the current state is Error but the result is different, then we need o clean up the extra labels
- // that were added after the state key was calculated
- // https://github.com/grafana/grafana/blob/1df4d332c982dc5e394201bb2ef35b442727ce63/pkg/services/ngalert/state/state.go#L298-L311
- // Usually, it happens in the case of classic conditions when the evalResult does not have labels.
- //
- // This is temporary change to make sure that the labels are not persistent in the state after it was in Error state
- // TODO yuri. Remove it when correct Error result with labels is provided
- if currentState.State == eval.Error && result.State != eval.Error {
- // This is possible because state was updated after the CacheID was calculated.
- _, curOk := currentState.Labels["ref_id"]
- _, resOk := result.Instance["ref_id"]
- if curOk && !resOk {
- delete(currentState.Labels, "ref_id")
- }
- _, curOk = currentState.Labels["datasource_uid"]
- _, resOk = result.Instance["datasource_uid"]
- if curOk && !resOk {
- delete(currentState.Labels, "datasource_uid")
- }
- }
-
- switch result.State {
- case eval.Normal:
- logger.Debug("Setting next state", "handler", "resultNormal")
- resultNormal(currentState, alertRule, result, logger, "")
- case eval.Alerting:
- logger.Debug("Setting next state", "handler", "resultAlerting")
- resultAlerting(currentState, alertRule, result, logger, "")
- case eval.Error:
- logger.Debug("Setting next state", "handler", "resultError")
- resultError(currentState, alertRule, result, logger)
- case eval.NoData:
- logger.Debug("Setting next state", "handler", "resultNoData")
- resultNoData(currentState, alertRule, result, logger)
- case eval.Pending: // we do not emit results with this state
- logger.Debug("Ignoring set next state as result is pending")
- }
-
- // Set reason iff: result and state are different, reason is not Alerting or Normal
- currentState.StateReason = ""
-
- if currentState.State != result.State &&
- result.State != eval.Normal &&
- result.State != eval.Alerting {
- currentState.StateReason = resultStateReason(result, alertRule)
- }
-
- // Set Resolved property so the scheduler knows to send a postable alert
- // to Alertmanager.
- newlyResolved := false
- if oldState == eval.Alerting && currentState.State == eval.Normal {
- currentState.ResolvedAt = &result.EvaluatedAt
- newlyResolved = true
- } else if currentState.State != eval.Normal && currentState.State != eval.Pending { // Retain the last resolved time for Normal->Normal and Normal->Pending.
- currentState.ResolvedAt = nil
- }
-
- if reason := shouldTakeImage(currentState.State, oldState, currentState.Image, newlyResolved); reason != "" {
- image := takeImageFn(reason)
- if image != nil {
- currentState.Image = image
- }
- }
-
- for key, val := range extraAnnotations {
- currentState.Annotations[key] = val
- }
-
- nextState := StateTransition{
- State: currentState,
- PreviousState: oldState,
- PreviousStateReason: oldReason,
- }
-
- if st.metrics != nil {
- st.metrics.StateUpdateDuration.Observe(st.clock.Now().Sub(start).Seconds())
- }
-
- return nextState
-}
-
-func resultStateReason(result eval.Result, rule *ngModels.AlertRule) string {
- if rule.ExecErrState == ngModels.KeepLastErrState || rule.NoDataState == ngModels.KeepLast {
- return ngModels.ConcatReasons(result.State.String(), ngModels.StateReasonKeepLast)
- }
-
- return result.State.String()
-}
-
func (st *Manager) GetAll(orgID int64) []*State {
allStates := st.cache.getAll(orgID)
return allStates
diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go
index aee77903096..54694664cca 100644
--- a/pkg/services/ngalert/state/state.go
+++ b/pkg/services/ngalert/state/state.go
@@ -7,6 +7,7 @@ import (
"fmt"
"maps"
"math"
+ "net/url"
"strings"
"time"
@@ -76,6 +77,35 @@ type State struct {
EvaluationDuration time.Duration
}
+func newState(ctx context.Context, log log.Logger, alertRule *models.AlertRule, result eval.Result, extraLabels data.Labels, externalURL *url.URL) *State {
+ lbs, annotations := expandAnnotationsAndLabels(ctx, log, alertRule, result, extraLabels, externalURL)
+
+ cacheID := lbs.Fingerprint()
+ // For new states, we set StartsAt & EndsAt to EvaluatedAt as this is the
+ // expected value for a Normal state during state transition.
+ return &State{
+ OrgID: alertRule.OrgID,
+ AlertRuleUID: alertRule.UID,
+ CacheID: cacheID,
+ State: eval.Normal,
+ StateReason: "",
+ ResultFingerprint: result.Instance.Fingerprint(), // remember original result fingerprint
+ LatestResult: nil,
+ Error: nil,
+ Image: nil,
+ Annotations: annotations,
+ Labels: lbs,
+ Values: nil,
+ StartsAt: result.EvaluatedAt,
+ EndsAt: result.EvaluatedAt,
+ ResolvedAt: nil,
+ LastSentAt: nil,
+ LastEvaluationString: "",
+ LastEvaluationTime: result.EvaluatedAt,
+ EvaluationDuration: result.EvaluationDuration,
+ }
+}
+
// Copy creates a shallow copy of the State except for labels and annotations.
func (a *State) Copy() *State {
// Deep copy annotations and labels
@@ -664,3 +694,147 @@ func GetRuleExtraLabels(l log.Logger, rule *models.AlertRule, folderTitle string
}
return extraLabels
}
+
+func patch(newState, existingState *State, result eval.Result) {
+ // if there is existing state, copy over the current values that may be needed to determine the final state.
+ // TODO remove some unnecessary assignments below because they are overridden in setNextState
+ newState.State = existingState.State
+ newState.StateReason = existingState.StateReason
+ newState.Image = existingState.Image
+ newState.LatestResult = existingState.LatestResult
+ newState.Error = existingState.Error
+ newState.Values = existingState.Values
+ newState.LastEvaluationString = existingState.LastEvaluationString
+ newState.StartsAt = existingState.StartsAt
+ newState.EndsAt = existingState.EndsAt
+ newState.ResolvedAt = existingState.ResolvedAt
+ newState.LastSentAt = existingState.LastSentAt
+ // Annotations can change over time, however we also want to maintain
+ // certain annotations across evaluations
+ for key := range models.InternalAnnotationNameSet { // Changing in
+ value, ok := existingState.Annotations[key]
+ if !ok {
+ continue
+ }
+ // If the annotation is not present then it should be copied from
+ // the current state to the new state
+ if _, ok = newState.Annotations[key]; !ok {
+ newState.Annotations[key] = value
+ }
+ }
+
+ // if the current state is "data source error" then it may have additional labels that may not exist in the new state.
+ // See https://github.com/grafana/grafana/blob/c7fdf8ce706c2c9d438f5e6eabd6e580bac4946b/pkg/services/ngalert/state/state.go#L161-L163
+ // copy known labels over to the new instance, it can help reduce flapping
+ // TODO fix this?
+ if existingState.State == eval.Error && result.State == eval.Error {
+ setIfExist := func(lbl string) {
+ if v, ok := existingState.Labels[lbl]; ok {
+ newState.Labels[lbl] = v
+ }
+ }
+ setIfExist("datasource_uid")
+ setIfExist("ref_id")
+ }
+}
+
+func (a *State) transition(alertRule *models.AlertRule, result eval.Result, extraAnnotations data.Labels, logger log.Logger, takeImageFn takeImageFn) StateTransition {
+ a.LastEvaluationTime = result.EvaluatedAt
+ a.EvaluationDuration = result.EvaluationDuration
+ a.SetNextValues(result)
+ a.LatestResult = &Evaluation{
+ EvaluationTime: result.EvaluatedAt,
+ EvaluationState: result.State,
+ Values: a.Values,
+ Condition: alertRule.Condition,
+ }
+ a.LastEvaluationString = result.EvaluationString
+ oldState := a.State
+ oldReason := a.StateReason
+
+ // Add the instance to the log context to help correlate log lines for a state
+ logger = logger.New("instance", result.Instance)
+
+ // if the current state is Error but the result is different, then we need o clean up the extra labels
+ // that were added after the state key was calculated
+ // https://github.com/grafana/grafana/blob/1df4d332c982dc5e394201bb2ef35b442727ce63/pkg/services/ngalert/state/state.go#L298-L311
+ // Usually, it happens in the case of classic conditions when the evalResult does not have labels.
+ //
+ // This is temporary change to make sure that the labels are not persistent in the state after it was in Error state
+ // TODO yuri. Remove it when correct Error result with labels is provided
+ if a.State == eval.Error && result.State != eval.Error {
+ // This is possible because state was updated after the CacheID was calculated.
+ _, curOk := a.Labels["ref_id"]
+ _, resOk := result.Instance["ref_id"]
+ if curOk && !resOk {
+ delete(a.Labels, "ref_id")
+ }
+ _, curOk = a.Labels["datasource_uid"]
+ _, resOk = result.Instance["datasource_uid"]
+ if curOk && !resOk {
+ delete(a.Labels, "datasource_uid")
+ }
+ }
+
+ switch result.State {
+ case eval.Normal:
+ logger.Debug("Setting next state", "handler", "resultNormal")
+ resultNormal(a, alertRule, result, logger, "")
+ case eval.Alerting:
+ logger.Debug("Setting next state", "handler", "resultAlerting")
+ resultAlerting(a, alertRule, result, logger, "")
+ case eval.Error:
+ logger.Debug("Setting next state", "handler", "resultError")
+ resultError(a, alertRule, result, logger)
+ case eval.NoData:
+ logger.Debug("Setting next state", "handler", "resultNoData")
+ resultNoData(a, alertRule, result, logger)
+ case eval.Pending: // we do not emit results with this state
+ logger.Debug("Ignoring set next state as result is pending")
+ }
+
+ // Set reason iff: result and state are different, reason is not Alerting or Normal
+ a.StateReason = ""
+
+ if a.State != result.State &&
+ result.State != eval.Normal &&
+ result.State != eval.Alerting {
+ a.StateReason = resultStateReason(result, alertRule)
+ }
+
+ // Set Resolved property so the scheduler knows to send a postable alert
+ // to Alertmanager.
+ newlyResolved := false
+ if oldState == eval.Alerting && a.State == eval.Normal {
+ a.ResolvedAt = &result.EvaluatedAt
+ newlyResolved = true
+ } else if a.State != eval.Normal && a.State != eval.Pending { // Retain the last resolved time for Normal->Normal and Normal->Pending.
+ a.ResolvedAt = nil
+ }
+
+ if reason := shouldTakeImage(a.State, oldState, a.Image, newlyResolved); reason != "" {
+ image := takeImageFn(reason)
+ if image != nil {
+ a.Image = image
+ }
+ }
+
+ for key, val := range extraAnnotations {
+ a.Annotations[key] = val
+ }
+
+ nextState := StateTransition{
+ State: a,
+ PreviousState: oldState,
+ PreviousStateReason: oldReason,
+ }
+ return nextState
+}
+
+func resultStateReason(result eval.Result, rule *models.AlertRule) string {
+ if rule.ExecErrState == models.KeepLastErrState || rule.NoDataState == models.KeepLast {
+ return models.ConcatReasons(result.State.String(), models.StateReasonKeepLast)
+ }
+
+ return result.State.String()
+}
diff --git a/pkg/services/ngalert/state/cache_bench_test.go b/pkg/services/ngalert/state/state_bench_test.go
similarity index 85%
rename from pkg/services/ngalert/state/cache_bench_test.go
rename to pkg/services/ngalert/state/state_bench_test.go
index 357eabdd9e3..8f1ec2550c8 100644
--- a/pkg/services/ngalert/state/cache_bench_test.go
+++ b/pkg/services/ngalert/state/state_bench_test.go
@@ -14,7 +14,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
-func BenchmarkGetOrCreateTest(b *testing.B) {
+func BenchmarkCreateAndPatch(b *testing.B) {
cache := newCache()
rule := models.RuleGen.With(func(rule *models.AlertRule) {
for i := 0; i < 2; i++ {
@@ -43,7 +43,11 @@ func BenchmarkGetOrCreateTest(b *testing.B) {
// values := make([]int64, count)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
- _ = cache.create(ctx, log, rule, result, nil, u)
+ s := newState(ctx, log, rule, result, nil, u)
+ current := cache.get(rule.OrgID, rule.UID, s.CacheID)
+ if current == nil {
+ patch(s, current, result)
+ }
}
})
}
diff --git a/pkg/services/ngalert/state/state_test.go b/pkg/services/ngalert/state/state_test.go
index 2515fe33190..7216784a65c 100644
--- a/pkg/services/ngalert/state/state_test.go
+++ b/pkg/services/ngalert/state/state_test.go
@@ -3,14 +3,17 @@ package state
import (
"context"
"errors"
+ "fmt"
"math"
"math/rand"
+ "net/url"
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/golang/mock/gomock"
"github.com/google/uuid"
+ "github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -801,3 +804,331 @@ func TestGetRuleExtraLabels(t *testing.T) {
})
}
}
+
+func TestNewState(t *testing.T) {
+ url := &url.URL{
+ Scheme: "http",
+ Host: "localhost:3000",
+ Path: "/test",
+ }
+ l := log.New("test")
+
+ gen := ngmodels.RuleGen
+ generateRule := gen.With(gen.WithNotEmptyLabels(5, "rule-")).GenerateRef
+
+ t.Run("should combine all labels", func(t *testing.T) {
+ rule := generateRule()
+
+ extraLabels := ngmodels.GenerateAlertLabels(5, "extra-")
+ result := eval.Result{
+ Instance: ngmodels.GenerateAlertLabels(5, "result-"),
+ }
+ state := newState(context.Background(), l, rule, result, extraLabels, url)
+ for key, expected := range extraLabels {
+ require.Equal(t, expected, state.Labels[key])
+ }
+ assert.Len(t, state.Labels, len(extraLabels)+len(rule.Labels)+len(result.Instance))
+ for key, expected := range extraLabels {
+ assert.Equal(t, expected, state.Labels[key])
+ }
+ for key, expected := range rule.Labels {
+ assert.Equal(t, expected, state.Labels[key])
+ }
+ for key, expected := range result.Instance {
+ assert.Equal(t, expected, state.Labels[key])
+ }
+ })
+ t.Run("extra labels should take precedence over rule and result labels", func(t *testing.T) {
+ rule := generateRule()
+
+ extraLabels := ngmodels.GenerateAlertLabels(2, "extra-")
+
+ result := eval.Result{
+ Instance: ngmodels.GenerateAlertLabels(5, "result-"),
+ }
+ for key := range extraLabels {
+ rule.Labels[key] = "rule-" + util.GenerateShortUID()
+ result.Instance[key] = "result-" + util.GenerateShortUID()
+ }
+
+ state := newState(context.Background(), l, rule, result, extraLabels, url)
+ for key, expected := range extraLabels {
+ require.Equal(t, expected, state.Labels[key])
+ }
+ })
+ t.Run("rule labels should take precedence over result labels", func(t *testing.T) {
+ rule := generateRule()
+
+ extraLabels := ngmodels.GenerateAlertLabels(2, "extra-")
+
+ result := eval.Result{
+ Instance: ngmodels.GenerateAlertLabels(5, "result-"),
+ }
+ for key := range rule.Labels {
+ result.Instance[key] = "result-" + util.GenerateShortUID()
+ }
+ state := newState(context.Background(), l, rule, result, extraLabels, url)
+ for key, expected := range rule.Labels {
+ require.Equal(t, expected, state.Labels[key])
+ }
+ })
+ t.Run("rule labels should be able to be expanded with result and extra labels", func(t *testing.T) {
+ result := eval.Result{
+ Instance: ngmodels.GenerateAlertLabels(5, "result-"),
+ }
+ rule := generateRule()
+
+ extraLabels := ngmodels.GenerateAlertLabels(2, "extra-")
+
+ labelTemplates := make(data.Labels)
+ for key := range extraLabels {
+ labelTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
+ }
+ for key := range result.Instance {
+ labelTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
+ }
+ rule.Labels = labelTemplates
+
+ state := newState(context.Background(), l, rule, result, extraLabels, url)
+ for key, expected := range extraLabels {
+ assert.Equal(t, expected, state.Labels["rule-"+key])
+ }
+ for key, expected := range result.Instance {
+ assert.Equal(t, expected, state.Labels["rule-"+key])
+ }
+ })
+ t.Run("rule annotations should be able to be expanded with result and extra labels", func(t *testing.T) {
+ result := eval.Result{
+ Instance: ngmodels.GenerateAlertLabels(5, "result-"),
+ }
+
+ rule := generateRule()
+
+ extraLabels := ngmodels.GenerateAlertLabels(2, "extra-")
+
+ annotationTemplates := make(data.Labels)
+ for key := range extraLabels {
+ annotationTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
+ }
+ for key := range result.Instance {
+ annotationTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
+ }
+ rule.Annotations = annotationTemplates
+
+ state := newState(context.Background(), l, rule, result, extraLabels, url)
+ for key, expected := range extraLabels {
+ assert.Equal(t, expected, state.Annotations["rule-"+key])
+ }
+ for key, expected := range result.Instance {
+ assert.Equal(t, expected, state.Annotations["rule-"+key])
+ }
+ })
+ t.Run("when result labels collide with system labels from LabelsUserCannotSpecify", func(t *testing.T) {
+ result := eval.Result{
+ Instance: ngmodels.GenerateAlertLabels(5, "result-"),
+ }
+ m := ngmodels.LabelsUserCannotSpecify
+ t.Cleanup(func() {
+ ngmodels.LabelsUserCannotSpecify = m
+ })
+
+ ngmodels.LabelsUserCannotSpecify = map[string]struct{}{
+ "__label1__": {},
+ "label2__": {},
+ "__label3": {},
+ "label4": {},
+ }
+ result.Instance["__label1__"] = uuid.NewString()
+ result.Instance["label2__"] = uuid.NewString()
+ result.Instance["__label3"] = uuid.NewString()
+ result.Instance["label4"] = uuid.NewString()
+
+ rule := generateRule()
+
+ state := newState(context.Background(), l, rule, result, nil, url)
+
+ for key := range ngmodels.LabelsUserCannotSpecify {
+ assert.NotContains(t, state.Labels, key)
+ }
+ assert.Contains(t, state.Labels, "label1")
+ assert.Equal(t, state.Labels["label1"], result.Instance["__label1__"])
+
+ assert.Contains(t, state.Labels, "label2")
+ assert.Equal(t, state.Labels["label2"], result.Instance["label2__"])
+
+ assert.Contains(t, state.Labels, "label3")
+ assert.Equal(t, state.Labels["label3"], result.Instance["__label3"])
+
+ assert.Contains(t, state.Labels, "label4_user")
+ assert.Equal(t, state.Labels["label4_user"], result.Instance["label4"])
+
+ t.Run("should drop label if renamed collides with existing", func(t *testing.T) {
+ result.Instance["label1"] = uuid.NewString()
+ result.Instance["label1_user"] = uuid.NewString()
+ result.Instance["label4_user"] = uuid.NewString()
+
+ state = newState(context.Background(), l, rule, result, nil, url)
+ assert.NotContains(t, state.Labels, "__label1__")
+ assert.Contains(t, state.Labels, "label1")
+ assert.Equal(t, state.Labels["label1"], result.Instance["label1"])
+ assert.Equal(t, state.Labels["label1_user"], result.Instance["label1_user"])
+
+ assert.NotContains(t, state.Labels, "label4")
+ assert.Equal(t, state.Labels["label4_user"], result.Instance["label4_user"])
+ })
+ })
+
+ t.Run("creates a state with preset fields if there is no current state", func(t *testing.T) {
+ rule := generateRule()
+
+ extraLabels := ngmodels.GenerateAlertLabels(2, "extra-")
+
+ result := eval.Result{
+ Instance: ngmodels.GenerateAlertLabels(5, "result-"),
+ }
+
+ expectedLbl, expectedAnn := expandAnnotationsAndLabels(context.Background(), l, rule, result, extraLabels, url)
+
+ state := newState(context.Background(), l, rule, result, extraLabels, url)
+
+ assert.Equal(t, rule.OrgID, state.OrgID)
+ assert.Equal(t, rule.UID, state.AlertRuleUID)
+ assert.Equal(t, state.Labels.Fingerprint(), state.CacheID)
+ assert.Equal(t, result.State, state.State)
+ assert.Equal(t, "", state.StateReason)
+ assert.Equal(t, result.Instance.Fingerprint(), state.ResultFingerprint)
+ assert.Nil(t, state.LatestResult)
+ assert.Nil(t, state.Error)
+ assert.Nil(t, state.Image)
+ assert.EqualValues(t, expectedAnn, state.Annotations)
+ assert.EqualValues(t, expectedLbl, state.Labels)
+ assert.Nil(t, state.Values)
+ assert.Equal(t, result.EvaluatedAt, state.StartsAt)
+ assert.Equal(t, result.EvaluatedAt, state.EndsAt)
+ assert.Nil(t, state.ResolvedAt)
+ assert.Nil(t, state.LastSentAt)
+ assert.Equal(t, "", state.LastEvaluationString)
+ assert.Equal(t, result.EvaluatedAt, state.LastEvaluationTime)
+ assert.Equal(t, result.EvaluationDuration, state.EvaluationDuration)
+ })
+}
+
+func TestPatch(t *testing.T) {
+ key := ngmodels.GenerateRuleKey(1)
+ t.Run("it populates some fields from the current state if it exists", func(t *testing.T) {
+ result := eval.Result{
+ Instance: ngmodels.GenerateAlertLabels(5, "result-"),
+ }
+
+ state := randomSate(key)
+ orig := state.Copy()
+ current := randomSate(key)
+
+ patch(&state, ¤t, result)
+
+ // Fields that should not change
+ assert.Equal(t, orig.OrgID, state.OrgID)
+ assert.Equal(t, orig.AlertRuleUID, state.AlertRuleUID)
+ assert.Equal(t, orig.CacheID, state.CacheID)
+ assert.Equal(t, orig.ResultFingerprint, state.ResultFingerprint)
+ assert.EqualValues(t, orig.Annotations, state.Annotations)
+ assert.EqualValues(t, orig.Labels, state.Labels)
+ assert.Equal(t, orig.LastEvaluationTime, state.LastEvaluationTime)
+ assert.Equal(t, orig.EvaluationDuration, state.EvaluationDuration)
+
+ assert.Equal(t, current.State, state.State)
+ assert.Equal(t, current.StateReason, state.StateReason)
+ assert.Equal(t, current.Image, state.Image)
+ assert.Equal(t, current.LatestResult, state.LatestResult)
+ assert.Equal(t, current.Error, state.Error)
+ assert.Equal(t, current.Values, state.Values)
+ assert.Equal(t, current.StartsAt, state.StartsAt)
+ assert.Equal(t, current.EndsAt, state.EndsAt)
+ assert.Equal(t, current.ResolvedAt, state.ResolvedAt)
+ assert.Equal(t, current.LastSentAt, state.LastSentAt)
+ assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString)
+ })
+
+ t.Run("copies system-owned annotations from current state", func(t *testing.T) {
+ state := randomSate(key)
+ orig := state.Copy()
+ expectedAnnotations := data.Labels(state.Annotations).Copy()
+ current := randomSate(key)
+
+ for key := range ngmodels.InternalAnnotationNameSet {
+ val := util.GenerateShortUID()
+ current.Annotations[key] = val
+ expectedAnnotations[key] = val
+ }
+
+ result := eval.Result{
+ Instance: ngmodels.GenerateAlertLabels(5, "result-"),
+ }
+
+ patch(&state, ¤t, result)
+
+ assert.EqualValues(t, expectedAnnotations, state.Annotations)
+ assert.Equal(t, current.State, state.State)
+ assert.Equal(t, current.StateReason, state.StateReason)
+ assert.Equal(t, current.Image, state.Image)
+ assert.Equal(t, current.LatestResult, state.LatestResult)
+ assert.Equal(t, current.Error, state.Error)
+ assert.Equal(t, current.Values, state.Values)
+ assert.Equal(t, current.StartsAt, state.StartsAt)
+ assert.Equal(t, current.EndsAt, state.EndsAt)
+ assert.Equal(t, current.ResolvedAt, state.ResolvedAt)
+ assert.Equal(t, current.LastSentAt, state.LastSentAt)
+ assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString)
+
+ // Fields that should not change
+ assert.Equal(t, orig.OrgID, state.OrgID)
+ assert.Equal(t, orig.AlertRuleUID, state.AlertRuleUID)
+ assert.Equal(t, orig.CacheID, state.CacheID)
+ assert.Equal(t, orig.ResultFingerprint, state.ResultFingerprint)
+ assert.EqualValues(t, orig.Labels, state.Labels)
+ assert.Equal(t, orig.LastEvaluationTime, state.LastEvaluationTime)
+ assert.Equal(t, orig.EvaluationDuration, state.EvaluationDuration)
+ })
+
+ t.Run("if result Error and current state is Error it should copy datasource_uid and ref_id labels", func(t *testing.T) {
+ state := randomSate(key)
+ orig := state.Copy()
+ current := randomSate(key)
+ current.State = eval.Error
+ current.Labels["datasource_uid"] = util.GenerateShortUID()
+ current.Labels["ref_id"] = util.GenerateShortUID()
+
+ result := eval.Result{
+ Instance: ngmodels.GenerateAlertLabels(5, "result-"),
+ State: eval.Error,
+ }
+
+ expectedLabels := orig.Labels.Copy()
+ expectedLabels["datasource_uid"] = current.Labels["datasource_uid"]
+ expectedLabels["ref_id"] = current.Labels["ref_id"]
+
+ patch(&state, ¤t, result)
+
+ assert.Equal(t, expectedLabels, state.Labels)
+ assert.Equal(t, current.State, state.State)
+ assert.Equal(t, current.StateReason, state.StateReason)
+ assert.Equal(t, current.Image, state.Image)
+ assert.Equal(t, current.LatestResult, state.LatestResult)
+ assert.Equal(t, current.Error, state.Error)
+ assert.Equal(t, current.Values, state.Values)
+ assert.Equal(t, current.StartsAt, state.StartsAt)
+ assert.Equal(t, current.EndsAt, state.EndsAt)
+ assert.Equal(t, current.ResolvedAt, state.ResolvedAt)
+ assert.Equal(t, current.LastSentAt, state.LastSentAt)
+ assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString)
+
+ // Fields that should not change
+ assert.Equal(t, orig.OrgID, state.OrgID)
+ assert.Equal(t, orig.AlertRuleUID, state.AlertRuleUID)
+ assert.Equal(t, orig.CacheID, state.CacheID)
+ assert.Equal(t, orig.ResultFingerprint, state.ResultFingerprint)
+ assert.Equal(t, orig.LastEvaluationTime, state.LastEvaluationTime)
+ assert.Equal(t, orig.EvaluationDuration, state.EvaluationDuration)
+ assert.EqualValues(t, orig.Annotations, state.Annotations)
+ })
+}
diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go
index ca6fef68b50..644058909d4 100644
--- a/pkg/services/pluginsintegration/loader/loader_test.go
+++ b/pkg/services/pluginsintegration/loader/loader_test.go
@@ -105,6 +105,7 @@ func TestLoader_Load(t *testing.T) {
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -200,8 +201,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -248,8 +251,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -309,8 +314,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -423,8 +430,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -504,8 +513,10 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -615,8 +626,10 @@ func TestLoader_Load_CustomSource(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -696,8 +709,10 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -801,8 +816,10 @@ func TestLoader_Load_RBACReady(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -883,8 +900,10 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) {
ExposedComponents: []string{},
}},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -964,8 +983,10 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -1060,8 +1081,10 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) {
{Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-datasource"},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -1272,8 +1295,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -1314,8 +1339,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -1463,8 +1490,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@@ -1512,8 +1541,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
},
Extensions: plugins.Extensions{
- AddedLinks: []plugins.AddedLink{},
- AddedComponents: []plugins.AddedComponent{},
+ AddedLinks: []plugins.AddedLink{},
+ AddedComponents: []plugins.AddedComponent{},
+ AddedFunctions: []plugins.AddedFunction{},
+
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/containing-id/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/containing-id/dashboard1.json
index 94b5c9a1c02..668b7ba4f1b 100644
--- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/containing-id/dashboard1.json
+++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/containing-id/dashboard1.json
@@ -30,17 +30,6 @@
"collapse": false,
"enable": true,
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"refresh_intervals": [
"5s",
"10s",
diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/dashboard-with-uid/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/dashboard-with-uid/dashboard1.json
index c0ab4838bf3..c69015426a6 100644
--- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/dashboard-with-uid/dashboard1.json
+++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/dashboard-with-uid/dashboard1.json
@@ -134,17 +134,6 @@
"collapse": false,
"enable": true,
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"refresh_intervals": [
"5s",
"10s",
diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard1.json
index 8c8cf42fc78..b45c8dece35 100644
--- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard1.json
+++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard1.json
@@ -134,17 +134,6 @@
"collapse": false,
"enable": true,
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"refresh_intervals": [
"5s",
"10s",
diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard2.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard2.json
index 94d29339a13..aa15ce8a12d 100644
--- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard2.json
+++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard2.json
@@ -134,17 +134,6 @@
"collapse": false,
"enable": true,
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"refresh_intervals": [
"5s",
"10s",
diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderOne/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderOne/dashboard1.json
index 8c8cf42fc78..b45c8dece35 100644
--- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderOne/dashboard1.json
+++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderOne/dashboard1.json
@@ -134,17 +134,6 @@
"collapse": false,
"enable": true,
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"refresh_intervals": [
"5s",
"10s",
diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderTwo/dashboard2.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderTwo/dashboard2.json
index 94d29339a13..aa15ce8a12d 100644
--- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderTwo/dashboard2.json
+++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderTwo/dashboard2.json
@@ -134,17 +134,6 @@
"collapse": false,
"enable": true,
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"refresh_intervals": [
"5s",
"10s",
diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/root.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/root.json
index 6743fb1f6a6..4948686435d 100644
--- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/root.json
+++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/root.json
@@ -134,17 +134,6 @@
"collapse": false,
"enable": true,
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"refresh_intervals": [
"5s",
"10s",
diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/one-dashboard/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/one-dashboard/dashboard1.json
index 9f786032f0e..3fd0d5aa927 100644
--- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/one-dashboard/dashboard1.json
+++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/one-dashboard/dashboard1.json
@@ -134,17 +134,6 @@
"collapse": false,
"enable": true,
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"refresh_intervals": [
"5s",
"10s",
diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard1.json
index c0ab4838bf3..c69015426a6 100644
--- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard1.json
+++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard1.json
@@ -134,17 +134,6 @@
"collapse": false,
"enable": true,
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"refresh_intervals": [
"5s",
"10s",
diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard2.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard2.json
index c0ab4838bf3..c69015426a6 100644
--- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard2.json
+++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard2.json
@@ -134,17 +134,6 @@
"collapse": false,
"enable": true,
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"refresh_intervals": [
"5s",
"10s",
diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/unprovision/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/unprovision/dashboard1.json
index 8c8cf42fc78..b45c8dece35 100644
--- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/unprovision/dashboard1.json
+++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/unprovision/dashboard1.json
@@ -134,17 +134,6 @@
"collapse": false,
"enable": true,
"status": "Stable",
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
- ],
"refresh_intervals": [
"5s",
"10s",
diff --git a/pkg/services/publicdashboards/service/query.go b/pkg/services/publicdashboards/service/query.go
index 3d8731e895d..446f74ab3b6 100644
--- a/pkg/services/publicdashboards/service/query.go
+++ b/pkg/services/publicdashboards/service/query.go
@@ -8,16 +8,13 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
"github.com/grafana/grafana/pkg/api/dtos"
+ "github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/expr"
- "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/dashboards"
- "github.com/grafana/grafana/pkg/services/datasources"
- "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/publicdashboards/models"
"github.com/grafana/grafana/pkg/services/publicdashboards/validation"
- "github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/tsdb/grafanads"
)
@@ -37,8 +34,8 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT
return nil, models.ErrInternalServerError.Errorf("FindAnnotations: failed to unmarshal dashboard annotations: %w", err)
}
- anonymousUser := buildAnonymousUser(ctx, dash, pd.features)
-
+ // We don't have a signed in user for public dashboards. We are using Grafana's Identity to query the annotations.
+ svcCtx, svcIdent := identity.WithServiceIdentity(ctx, dash.OrgID)
uniqueEvents := make(map[int64]models.AnnotationEvent, 0)
for _, anno := range annoDto.Annotations.List {
// skip annotations that are not enabled or are not a grafana datasource
@@ -51,7 +48,7 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT
OrgID: dash.OrgID,
DashboardID: dash.ID,
DashboardUID: dash.UID,
- SignedInUser: anonymousUser,
+ SignedInUser: svcIdent,
}
if anno.Target != nil {
@@ -63,7 +60,7 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT
}
}
- annotationItems, err := pd.AnnotationsRepo.Find(ctx, annoQuery)
+ annotationItems, err := pd.AnnotationsRepo.Find(svcCtx, annoQuery)
if err != nil {
return nil, models.ErrInternalServerError.Errorf("FindAnnotations: failed to find annotations: %w", err)
}
@@ -139,8 +136,9 @@ func (pd *PublicDashboardServiceImpl) GetQueryDataResponse(ctx context.Context,
return nil, models.ErrPanelQueriesNotFound.Errorf("GetQueryDataResponse: failed to extract queries from panel")
}
- anonymousUser := buildAnonymousUser(ctx, dashboard, pd.features)
- res, err := pd.QueryDataService.QueryData(ctx, anonymousUser, skipDSCache, metricReq)
+ // We don't have a signed in user for public dashboards. We are using Grafana's Identity to query the datasource.
+ svcCtx, svcIdent := identity.WithServiceIdentity(ctx, dashboard.OrgID)
+ res, err := pd.QueryDataService.QueryData(svcCtx, svcIdent, skipDSCache, metricReq)
reqDatasources := metricReq.GetUniqueDatasourceTypes()
if err != nil {
@@ -180,92 +178,6 @@ func (pd *PublicDashboardServiceImpl) buildMetricRequest(dashboard *dashboards.D
}, nil
}
-// buildAnonymousUser creates a user with permissions to read from all datasources used in the dashboard
-func buildAnonymousUser(ctx context.Context, dashboard *dashboards.Dashboard, features featuremgmt.FeatureToggles) *user.SignedInUser {
- datasourceUids := getUniqueDashboardDatasourceUids(dashboard.Data)
-
- // Create a user with blank permissions
- anonymousUser := &user.SignedInUser{OrgID: dashboard.OrgID, Permissions: make(map[int64]map[string][]string)}
-
- // Scopes needed for Annotation queries
- annotationScopes := []string{accesscontrol.ScopeAnnotationsTypeDashboard}
- // Need to access all dashboards since tags annotations span across all dashboards
- dashboardScopes := []string{dashboards.ScopeDashboardsProvider.GetResourceAllScope()}
-
- // Scopes needed for datasource queries
- queryScopes := make([]string, 0)
- readScopes := make([]string, 0)
- for _, uid := range datasourceUids {
- scope := datasources.ScopeProvider.GetResourceScopeUID(uid)
- queryScopes = append(queryScopes, scope)
- readScopes = append(readScopes, scope)
- }
-
- // Apply all scopes to the actions we need the user to be able to perform
- permissions := make(map[string][]string)
- permissions[datasources.ActionQuery] = queryScopes
- permissions[datasources.ActionRead] = readScopes
- permissions[dashboards.ActionDashboardsRead] = dashboardScopes
- permissions[accesscontrol.ActionAnnotationsRead] = annotationScopes
-
- if features.IsEnabled(ctx, featuremgmt.FlagAnnotationPermissionUpdate) {
- permissions[accesscontrol.ActionAnnotationsRead] = dashboardScopes
- }
-
- anonymousUser.Permissions[dashboard.OrgID] = permissions
-
- return anonymousUser
-}
-
-func getUniqueDashboardDatasourceUids(dashboard *simplejson.Json) []string {
- var datasourceUids []string
- exists := map[string]bool{}
-
- // collapsed rows contain panels in a nested structure, so we need to flatten them before calculate unique uids
- flattenedPanels := getFlattenedPanels(dashboard)
-
- for _, panelObj := range flattenedPanels {
- panel := simplejson.NewFromAny(panelObj)
- uid := getDataSourceUidFromJson(panel)
-
- // if uid is for a mixed datasource, get the datasource uids from the targets
- if uid == "-- Mixed --" {
- for _, targetObj := range panel.Get("targets").MustArray() {
- target := simplejson.NewFromAny(targetObj)
- datasourceUid := getDataSourceUidFromJson(target)
- if _, ok := exists[datasourceUid]; !ok {
- datasourceUids = append(datasourceUids, datasourceUid)
- exists[datasourceUid] = true
- }
- }
- } else {
- if _, ok := exists[uid]; !ok {
- datasourceUids = append(datasourceUids, uid)
- exists[uid] = true
- }
- }
- }
-
- return datasourceUids
-}
-
-func getFlattenedPanels(dashboard *simplejson.Json) []any {
- var flatPanels []any
- for _, panelObj := range dashboard.Get("panels").MustArray() {
- panel := simplejson.NewFromAny(panelObj)
- // if the panel is a row and it is collapsed, get the queries from the panels inside the row
- // if it is not collapsed, the row does not have any panels
- if panel.Get("type").MustString() == "row" {
- if panel.Get("collapsed").MustBool() {
- flatPanels = append(flatPanels, panel.Get("panels").MustArray()...)
- }
- } else {
- flatPanels = append(flatPanels, panelObj)
- }
- }
- return flatPanels
-}
-
func groupQueriesByPanelId(dashboard *simplejson.Json) map[int64][]*simplejson.Json {
result := make(map[int64][]*simplejson.Json)
diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go
index f6aec672eac..0db1dfd08b0 100644
--- a/pkg/services/publicdashboards/service/query_test.go
+++ b/pkg/services/publicdashboards/service/query_test.go
@@ -11,8 +11,8 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
"github.com/grafana/grafana-plugin-sdk-go/data"
+ "github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/components/simplejson"
- "github.com/grafana/grafana/pkg/infra/db"
dashboard2 "github.com/grafana/grafana/pkg/kinds/dashboard"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/dashboards"
@@ -110,161 +110,6 @@ const (
"schemaVersion": 35
}`
- dashboardWithMixedDatasource = `
-{
- "panels": [
- {
- "datasource": {
- "type": "datasource",
- "uid": "-- Mixed --"
- },
- "id": 1,
- "targets": [
- {
- "datasource": {
- "type": "prometheus",
- "uid": "abc123"
- },
- "exemplar": true,
- "expr": "go_goroutines{job=\"$job\"}",
- "interval": "",
- "legendFormat": "",
- "refId": "A"
- },
- {
- "datasource": "6SOeCRrVk",
- "exemplar": true,
- "expr": "test{id=\"f0dd9b69-ad04-4342-8e79-ced8c245683b\", name=\"test\"}",
- "hide": false,
- "interval": "",
- "legendFormat": "",
- "refId": "B"
- }
- ],
- "title": "Panel Title",
- "type": "timeseries"
- },
- {
- "datasource": {
- "type": "prometheus",
- "uid": "_yxMP8Ynk"
- },
- "id": 2,
- "targets": [
- {
- "datasource": {
- "type": "prometheus",
- "uid": "_yxMP8Ynk"
- },
- "exemplar": true,
- "expr": "go_goroutines{job=\"$job\"}",
- "interval": "",
- "legendFormat": "",
- "refId": "A"
- }
- ],
- "title": "Panel Title",
- "type": "timeseries"
- },
- {
- "datasource": {
- "type": "prometheus",
- "uid": "_yxMP8Ynk"
- },
- "id": 3,
- "targets": [
- {
- "datasource": {
- "type": "prometheus",
- "uid": "_yxMP8Ynk"
- },
- "exemplar": true,
- "expr": "go_goroutines{job=\"$job\"}",
- "interval": "",
- "legendFormat": "",
- "refId": "A"
- }
- ],
- "title": "Panel Title",
- "type": "timeseries"
- }
- ],
- "schemaVersion": 35
-}`
-
- dashboardWithDuplicateDatasources = `
-{
- "panels": [
- {
- "datasource": {
- "type": "prometheus",
- "uid": "abc123"
- },
- "id": 1,
- "targets": [
- {
- "datasource": {
- "type": "prometheus",
- "uid": "abc123"
- },
- "exemplar": true,
- "expr": "go_goroutines{job=\"$job\"}",
- "interval": "",
- "legendFormat": "",
- "refId": "A"
- }
- ],
- "title": "Panel Title",
- "type": "timeseries"
- },
- {
- "datasource": {
- "type": "prometheus",
- "uid": "_yxMP8Ynk"
- },
- "id": 2,
- "targets": [
- {
- "datasource": {
- "type": "prometheus",
- "uid": "_yxMP8Ynk"
- },
- "exemplar": true,
- "expr": "go_goroutines{job=\"$job\"}",
- "interval": "",
- "legendFormat": "",
- "refId": "A"
- }
- ],
- "title": "Panel Title",
- "type": "timeseries"
- },
- {
- "datasource": {
- "type": "prometheus",
- "uid": "_yxMP8Ynk"
- },
- "id": 3,
- "targets": [
- {
- "datasource": {
- "type": "prometheus",
- "uid": "_yxMP8Ynk"
- },
- "exemplar": true,
- "expr": "go_goroutines{job=\"$job\"}",
- "interval": "",
- "legendFormat": "",
- "refId": "A"
- }
- ],
- "title": "Panel Title",
- "type": "timeseries"
- }
- ],
- "schemaVersion": 35
-}`
-
oldStyleDashboard = `
{
"panels": [
@@ -460,218 +305,6 @@ const (
],
"schemaVersion": 35
}`
-
- dashboardWithCollapsedRows = `
-{
-"panels": [
- {
- "gridPos": {
- "h": 1,
- "w": 24,
- "x": 0,
- "y": 0
- },
- "id": 12,
- "title": "Row title",
- "type": "row"
- },
- {
- "datasource": {
- "type": "prometheus",
- "uid": "qCbTUC37k"
- },
- "fieldConfig": {
- "defaults": {
- "color": {
- "mode": "palette-classic"
- },
- "custom": {
- "axisCenteredZero": false,
- "axisColorMode": "text",
- "axisLabel": "",
- "axisPlacement": "auto",
- "barAlignment": 0,
- "drawStyle": "line",
- "fillOpacity": 0,
- "gradientMode": "none",
- "hideFrom": {
- "legend": false,
- "tooltip": false,
- "viz": false
- },
- "lineInterpolation": "linear",
- "lineWidth": 1,
- "pointSize": 5,
- "scaleDistribution": {
- "type": "linear"
- },
- "showPoints": "auto",
- "spanNulls": false,
- "stacking": {
- "group": "A",
- "mode": "none"
- },
- "thresholdsStyle": {
- "mode": "off"
- }
- },
- "mappings": [],
- "thresholds": {
- "mode": "absolute",
- "steps": [
- {
- "color": "green",
- "value": null
- },
- {
- "color": "red",
- "value": 80
- }
- ]
- }
- },
- "overrides": []
- },
- "gridPos": {
- "h": 8,
- "w": 12,
- "x": 0,
- "y": 1
- },
- "id": 11,
- "options": {
- "legend": {
- "calcs": [],
- "displayMode": "list",
- "placement": "bottom",
- "showLegend": true
- },
- "tooltip": {
- "mode": "single",
- "sort": "none"
- }
- },
- "targets": [
- {
- "datasource": {
- "type": "prometheus",
- "uid": "qCbTUC37k"
- },
- "editorMode": "builder",
- "expr": "access_evaluation_duration_bucket",
- "legendFormat": "__auto",
- "range": true,
- "refId": "A"
- }
- ],
- "title": "Panel Title",
- "type": "timeseries"
- },
- {
- "collapsed": true,
- "gridPos": {
- "h": 1,
- "w": 24,
- "x": 0,
- "y": 9
- },
- "id": 10,
- "panels": [
- {
- "datasource": {
- "type": "influxdb",
- "uid": "P49A45DF074423DFB"
- },
- "fieldConfig": {
- "defaults": {
- "color": {
- "mode": "palette-classic"
- },
- "custom": {
- "axisCenteredZero": false,
- "axisColorMode": "text",
- "axisLabel": "",
- "axisPlacement": "auto",
- "barAlignment": 0,
- "drawStyle": "line",
- "fillOpacity": 0,
- "gradientMode": "none",
- "hideFrom": {
- "legend": false,
- "tooltip": false,
- "viz": false
- },
- "lineInterpolation": "linear",
- "lineWidth": 1,
- "pointSize": 5,
- "scaleDistribution": {
- "type": "linear"
- },
- "showPoints": "auto",
- "spanNulls": false,
- "stacking": {
- "group": "A",
- "mode": "none"
- },
- "thresholdsStyle": {
- "mode": "off"
- }
- },
- "mappings": [],
- "thresholds": {
- "mode": "absolute",
- "steps": [
- {
- "color": "green"
- },
- {
- "color": "red",
- "value": 80
- }
- ]
- }
- },
- "overrides": []
- },
- "gridPos": {
- "h": 9,
- "w": 12,
- "x": 0,
- "y": 10
- },
- "id": 8,
- "options": {
- "legend": {
- "calcs": [],
- "displayMode": "list",
- "placement": "bottom",
- "showLegend": true
- },
- "tooltip": {
- "mode": "single",
- "sort": "none"
- }
- },
- "pluginVersion": "9.4.0-pre",
- "targets": [
- {
- "datasource": {
- "type": "influxdb",
- "uid": "P49A45DF074423DFB"
- },
- "query": "// v.bucket, v.timeRangeStart, and v.timeRange stop are all variables supported by the flux plugin and influxdb\nfrom(bucket: v.bucket)\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_value\"] >= 10 and r[\"_value\"] <= 20)",
- "refId": "A"
- }
- ],
- "title": "Panel Title",
- "type": "timeseries"
- }
- ],
- "title": "Row title 1",
- "type": "row"
- }
- ]
-}`
)
func TestGetQueryDataResponse(t *testing.T) {
@@ -731,8 +364,7 @@ func TestGetQueryDataResponse(t *testing.T) {
func TestFindAnnotations(t *testing.T) {
color := "red"
name := "annoName"
- features := featuremgmt.WithFeatures(featuremgmt.FlagAnnotationPermissionUpdate)
- t.Run("will build anonymous user with correct permissions to get annotations", func(t *testing.T) {
+ t.Run("service identity has correct permissions to get annotations dashboards and query datasources", func(t *testing.T) {
fakeStore := &FakePublicDashboardStore{}
fakeStore.On("FindByAccessToken", mock.Anything, mock.AnythingOfType("string")).
Return(&PublicDashboard{Uid: "uid1", IsEnabled: true}, nil)
@@ -746,11 +378,14 @@ func TestFindAnnotations(t *testing.T) {
}
dash := dashboards.NewDashboard("testDashboard")
- items, _ := service.FindAnnotations(context.Background(), reqDTO, "abc123")
- anonUser := buildAnonymousUser(context.Background(), dash, features)
-
- assert.Equal(t, "dashboards:*", anonUser.Permissions[0]["dashboards:read"][0])
+ items, err := service.FindAnnotations(context.Background(), reqDTO, "abc123")
+ require.NoError(t, err)
assert.Len(t, items, 0)
+
+ _, svcIdent := identity.WithServiceIdentity(context.Background(), dash.OrgID)
+ require.Equal(t, "*", svcIdent.GetPermissions()["datasources:query"][0])
+ require.Equal(t, "*", svcIdent.GetPermissions()["dashboards:read"][0])
+ require.Equal(t, "*", svcIdent.GetPermissions()["annotations:read"][0])
})
t.Run("Test events from tag queries overwrite built-in annotation queries and duplicate events are not returned", func(t *testing.T) {
@@ -1121,47 +756,6 @@ func TestGetMetricRequest(t *testing.T) {
})
}
-func TestGetUniqueDashboardDatasourceUids(t *testing.T) {
- t.Run("can get unique datasource ids from dashboard", func(t *testing.T) {
- json, err := simplejson.NewJson([]byte(dashboardWithDuplicateDatasources))
- require.NoError(t, err)
-
- uids := getUniqueDashboardDatasourceUids(json)
- require.Len(t, uids, 2)
- require.Equal(t, "abc123", uids[0])
- require.Equal(t, "_yxMP8Ynk", uids[1])
- })
-
- t.Run("can get unique datasource ids from dashboard with a mixed datasource", func(t *testing.T) {
- json, err := simplejson.NewJson([]byte(dashboardWithMixedDatasource))
- require.NoError(t, err)
-
- uids := getUniqueDashboardDatasourceUids(json)
- require.Len(t, uids, 3)
- require.Equal(t, "abc123", uids[0])
- require.Equal(t, "6SOeCRrVk", uids[1])
- require.Equal(t, "_yxMP8Ynk", uids[2])
- })
-
- t.Run("can get no datasource uids from empty dashboard", func(t *testing.T) {
- json, err := simplejson.NewJson([]byte(`{"panels": {}}`))
- require.NoError(t, err)
-
- uids := getUniqueDashboardDatasourceUids(json)
- require.Len(t, uids, 0)
- })
-
- t.Run("can get unique datasource ids from dashboard with rows", func(t *testing.T) {
- json, err := simplejson.NewJson([]byte(dashboardWithCollapsedRows))
- require.NoError(t, err)
-
- uids := getUniqueDashboardDatasourceUids(json)
- require.Len(t, uids, 2)
- require.Equal(t, "qCbTUC37k", uids[0])
- require.Equal(t, "P49A45DF074423DFB", uids[1])
- })
-}
-
func TestBuildMetricRequest(t *testing.T) {
fakeDashboardService := &dashboards.FakeDashboardService{}
service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, nil, nil, fakeDashboardService, nil)
@@ -1318,39 +912,6 @@ func TestBuildMetricRequest(t *testing.T) {
})
}
-func TestBuildAnonymousUser(t *testing.T) {
- sqlStore, cfg := db.InitTestDBWithCfg(t)
- dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore))
- require.NoError(t, err)
- dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil)
- features := featuremgmt.WithFeatures()
-
- t.Run("will add datasource read and query permissions to user for each datasource in dashboard", func(t *testing.T) {
- user := buildAnonymousUser(context.Background(), dashboard, features)
-
- require.Equal(t, dashboard.OrgID, user.OrgID)
- require.Equal(t, "datasources:uid:ds1", user.Permissions[user.OrgID]["datasources:query"][0])
- require.Equal(t, "datasources:uid:ds3", user.Permissions[user.OrgID]["datasources:query"][1])
- require.Equal(t, "datasources:uid:ds1", user.Permissions[user.OrgID]["datasources:read"][0])
- require.Equal(t, "datasources:uid:ds3", user.Permissions[user.OrgID]["datasources:read"][1])
- })
- t.Run("will add dashboard and annotation permissions needed for getting annotations", func(t *testing.T) {
- user := buildAnonymousUser(context.Background(), dashboard, features)
-
- require.Equal(t, dashboard.OrgID, user.OrgID)
- require.Equal(t, "annotations:type:dashboard", user.Permissions[user.OrgID]["annotations:read"][0])
- require.Equal(t, "dashboards:*", user.Permissions[user.OrgID]["dashboards:read"][0])
- })
- t.Run("will add dashboard and annotation permissions needed for getting annotations when FlagAnnotationPermissionUpdate is enabled", func(t *testing.T) {
- features = featuremgmt.WithFeatures(featuremgmt.FlagAnnotationPermissionUpdate)
- user := buildAnonymousUser(context.Background(), dashboard, features)
-
- require.Equal(t, dashboard.OrgID, user.OrgID)
- require.Equal(t, "dashboards:*", user.Permissions[user.OrgID]["annotations:read"][0])
- require.Equal(t, "dashboards:*", user.Permissions[user.OrgID]["dashboards:read"][0])
- })
-}
-
func TestGroupQueriesByPanelId(t *testing.T) {
t.Run("can extract queries from dashboard with panel datasource string that has no datasource on panel targets", func(t *testing.T) {
json, err := simplejson.NewJson([]byte(oldStyleDashboard))
diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go
index d3239e2857d..9223981518c 100644
--- a/pkg/services/publicdashboards/service/service.go
+++ b/pkg/services/publicdashboards/service/service.go
@@ -13,6 +13,7 @@ import (
"go.opentelemetry.io/otel"
"github.com/grafana/grafana/pkg/api/dtos"
+ "github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -136,7 +137,11 @@ func (pd *PublicDashboardServiceImpl) Find(ctx context.Context, uid string) (*Pu
func (pd *PublicDashboardServiceImpl) FindDashboard(ctx context.Context, orgId int64, dashboardUid string) (*dashboards.Dashboard, error) {
ctx, span := tracer.Start(ctx, "publicdashboards.FindDashboard")
defer span.End()
- dash, err := pd.dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{UID: dashboardUid, OrgID: orgId})
+
+ // We don't have a signed in user for public dashboards. We are using Grafana's Identity to query the dashboard.
+ dash, err := identity.WithServiceIdentityFn(ctx, orgId, func(ctx context.Context) (*dashboards.Dashboard, error) {
+ return pd.dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{UID: dashboardUid, OrgID: orgId})
+ })
if err != nil {
var dashboardErr dashboards.DashboardErr
if ok := errors.As(err, &dashboardErr); ok {
diff --git a/pkg/services/secrets/migrator/migrator.go b/pkg/services/secrets/migrator/migrator.go
index 3581c2afd99..d0dd383a62d 100644
--- a/pkg/services/secrets/migrator/migrator.go
+++ b/pkg/services/secrets/migrator/migrator.go
@@ -51,6 +51,7 @@ func ProvideSecretsMigrator(
b64Secret{simpleSecret: simpleSecret{tableName: "user_external_session", columnName: "refresh_token"}, encoding: base64.StdEncoding},
b64Secret{simpleSecret: simpleSecret{tableName: "user_external_session", columnName: "session_id"}, encoding: base64.StdEncoding},
b64Secret{simpleSecret: simpleSecret{tableName: "user_external_session", columnName: "name_id"}, encoding: base64.StdEncoding},
+ // FIXME: Rotate provisioning secrets
}
return &SecretsMigrator{
diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go
index f53e7b72d9b..cde680f616d 100644
--- a/pkg/services/serviceaccounts/manager/service.go
+++ b/pkg/services/serviceaccounts/manager/service.go
@@ -17,8 +17,6 @@ import (
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apikey"
- "github.com/grafana/grafana/pkg/services/auth"
- "github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/serviceaccounts/database"
@@ -44,7 +42,6 @@ type ServiceAccountsService struct {
secretScanService secretscan.Checker
orgService org.Service
serverLock *serverlock.ServerLockService
- idService auth.IDService
secretScanEnabled bool
secretScanInterval time.Duration
@@ -61,7 +58,6 @@ func ProvideServiceAccountsService(
acService accesscontrol.Service,
permissions accesscontrol.ServiceAccountPermissionsService,
serverLockService *serverlock.ServerLockService,
- idService auth.IDService,
) (*ServiceAccountsService, error) {
serviceAccountsStore := database.ProvideServiceAccountsStore(
cfg,
@@ -81,7 +77,6 @@ func ProvideServiceAccountsService(
backgroundLog: log.New("serviceaccounts.background"),
orgService: orgService,
serverLock: serverLockService,
- idService: idService,
}
if err := RegisterRoles(acService); err != nil {
@@ -271,10 +266,6 @@ func (sa *ServiceAccountsService) UpdateServiceAccount(ctx context.Context, orgI
return nil, err
}
- if err := sa.idService.RemoveIDToken(ctx, &authn.Identity{ID: strconv.FormatInt(serviceAccountID, 10), Type: claims.TypeServiceAccount, OrgID: orgID}); err != nil {
- return nil, err
- }
-
return sa.store.UpdateServiceAccount(ctx, orgID, serviceAccountID, saForm)
}
diff --git a/pkg/storage/legacysql/dualwrite/managed_mode3.go b/pkg/storage/legacysql/dualwrite/managed_mode3.go
index af1cee40613..48dd2d65fff 100644
--- a/pkg/storage/legacysql/dualwrite/managed_mode3.go
+++ b/pkg/storage/legacysql/dualwrite/managed_mode3.go
@@ -37,20 +37,20 @@ func (m *service) NewStorage(gr schema.GroupResource,
}
return &mangedMode3{
- service: m,
- legacy: legacy,
- unified: storage,
- target: grafanarest.NewDualWriter(grafanarest.Mode3, legacy, storage, m.reg, gr.String()),
- gr: gr,
+ service: m,
+ legacy: legacy,
+ unified: storage,
+ dualwrite: grafanarest.NewDualWriter(grafanarest.Mode3, legacy, storage, m.reg, gr.String()),
+ gr: gr,
}, nil
}
type mangedMode3 struct {
- service Service
- legacy grafanarest.LegacyStorage
- unified grafanarest.Storage
- target grafanarest.Storage
- gr schema.GroupResource
+ service Service
+ legacy grafanarest.LegacyStorage
+ unified grafanarest.Storage
+ dualwrite grafanarest.Storage
+ gr schema.GroupResource
}
func (d *mangedMode3) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
@@ -67,68 +67,78 @@ func (d *mangedMode3) List(ctx context.Context, options *metainternalversion.Lis
return d.legacy.List(ctx, options)
}
-func (d *mangedMode3) isMigrating(ctx context.Context) error {
+func (d *mangedMode3) getWriter(ctx context.Context) (grafanarest.Storage, error) {
status, ok := d.service.Status(ctx, d.gr)
if ok && status.Migrating > 0 {
- return &apierrors.StatusError{
+ return nil, &apierrors.StatusError{
ErrStatus: metav1.Status{
Code: http.StatusServiceUnavailable,
Message: "the system is migrating",
},
}
}
- return nil
+ if status.WriteLegacy {
+ if status.WriteUnified {
+ return d.dualwrite, nil
+ }
+ return d.legacy, nil // only write legacy (mode0)
+ }
+ return d.unified, nil // only write unified (mode4)
}
func (d *mangedMode3) Create(ctx context.Context, in runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
- if err := d.isMigrating(ctx); err != nil {
+ store, err := d.getWriter(ctx)
+ if err != nil {
return nil, err
}
- return d.target.Create(ctx, in, createValidation, options)
+ return store.Create(ctx, in, createValidation, options)
}
func (d *mangedMode3) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
- if err := d.isMigrating(ctx); err != nil {
+ store, err := d.getWriter(ctx)
+ if err != nil {
return nil, false, err
}
- return d.target.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
+ return store.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
}
func (d *mangedMode3) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
- if err := d.isMigrating(ctx); err != nil {
+ store, err := d.getWriter(ctx)
+ if err != nil {
return nil, false, err
}
- return d.target.Delete(ctx, name, deleteValidation, options)
+ return store.Delete(ctx, name, deleteValidation, options)
}
// DeleteCollection overrides the behavior of the generic DualWriter and deletes from both LegacyStorage and Storage.
func (d *mangedMode3) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *metainternalversion.ListOptions) (runtime.Object, error) {
- if err := d.isMigrating(ctx); err != nil {
+ store, err := d.getWriter(ctx)
+ if err != nil {
return nil, err
}
- return d.target.DeleteCollection(ctx, deleteValidation, options, listOptions)
+ return store.DeleteCollection(ctx, deleteValidation, options, listOptions)
}
func (d *mangedMode3) Destroy() {
- d.target.Destroy()
+ d.dualwrite.Destroy()
}
func (d *mangedMode3) GetSingularName() string {
- return d.target.GetSingularName()
+ return d.unified.GetSingularName()
}
func (d *mangedMode3) NamespaceScoped() bool {
- return d.target.NamespaceScoped()
+ return d.unified.NamespaceScoped()
}
func (d *mangedMode3) New() runtime.Object {
- return d.target.New()
+ return d.unified.New()
}
func (d *mangedMode3) NewList() runtime.Object {
- return d.target.NewList()
+ return d.unified.NewList()
}
func (d *mangedMode3) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
- return d.target.ConvertToTable(ctx, object, tableOptions)
+ return d.unified.ConvertToTable(ctx, object, tableOptions)
}
diff --git a/pkg/storage/legacysql/dualwrite/service.go b/pkg/storage/legacysql/dualwrite/service.go
index 1f231414bf5..563a14aa6b6 100644
--- a/pkg/storage/legacysql/dualwrite/service.go
+++ b/pkg/storage/legacysql/dualwrite/service.go
@@ -20,11 +20,10 @@ func ProvideService(features featuremgmt.FeatureToggles, reg prometheus.Register
}
return &service{
- db: newFileDB(path),
- reg: reg,
- enabled: features.IsEnabledGlobally(featuremgmt.FlagManagedDualWriter),
- // TODO: when we can "export" from legacy, this can enabled along with provisioning
- // || features.IsEnabledGlobally(featuremgmt.FlagProvisioning), // required for git provisioning
+ db: newFileDB(path),
+ reg: reg,
+ enabled: features.IsEnabledGlobally(featuremgmt.FlagManagedDualWriter) ||
+ features.IsEnabledGlobally(featuremgmt.FlagProvisioning), // required for git provisioning
}
}
diff --git a/pkg/storage/legacysql/dualwrite/storage_sql_mig.go b/pkg/storage/legacysql/dualwrite/storage_sql_mig.go
index 3bb4650ff78..ce7a6f3f876 100644
--- a/pkg/storage/legacysql/dualwrite/storage_sql_mig.go
+++ b/pkg/storage/legacysql/dualwrite/storage_sql_mig.go
@@ -2,18 +2,23 @@ package dualwrite
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
+// Not yet used... but you get the idea
func AddUnifiedStatusMigrations(mg *migrator.Migrator) {
resourceStorageStatus := migrator.Table{
Name: "resource_storage_status",
Columns: []*migrator.Column{
{Name: "group", Type: migrator.DB_NVarchar, Length: 190, Nullable: false},
{Name: "resource", Type: migrator.DB_NVarchar, Length: 190, Nullable: false},
- {Name: "migrated", Type: migrator.DB_BigInt, Nullable: false}, // Timestamp when we can start trusting unified storage
- {Name: "migrating", Type: migrator.DB_BigInt, Nullable: false}, // Actively running a migration (start timestamp)
+ {Name: "write_legacy", Type: migrator.DB_Bool, Nullable: false, Default: "TRUE"},
+ {Name: "write_unified", Type: migrator.DB_Bool, Nullable: false, Default: "TRUE"},
+ {Name: "read_unified", Type: migrator.DB_Bool, Nullable: false},
+ {Name: "migrating", Type: migrator.DB_BigInt, Nullable: false}, // Timestamp Actively running a migration (start timestamp)
+ {Name: "migrated", Type: migrator.DB_BigInt, Nullable: false}, // Timestamp job finished
+ {Name: "runtime", Type: migrator.DB_Bool, Nullable: false, Default: "TRUE"},
{Name: "update_key", Type: migrator.DB_BigInt, Nullable: false}, // optimistic lock key -- required for update
},
Indices: []*migrator.Index{
- {Cols: []string{"group", "resource", "namespace"}, Type: migrator.UniqueIndex},
+ {Cols: []string{"group", "resource"}, Type: migrator.UniqueIndex},
},
}
mg.AddMigration("create resource_storage_status table", migrator.NewAddTableMigration(resourceStorageStatus))
diff --git a/pkg/storage/legacysql/dualwrite/types.go b/pkg/storage/legacysql/dualwrite/types.go
index 5dc4401db6b..3000ba91947 100644
--- a/pkg/storage/legacysql/dualwrite/types.go
+++ b/pkg/storage/legacysql/dualwrite/types.go
@@ -22,7 +22,7 @@ type StorageStatus struct {
Migrated int64 `json:"migrated" xorm:"migrated"`
// Timestamp when a migration *started* this should be cleared when finished
- // While migrating all write commands will be unavaliable
+ // While migrating all write commands will be unavailable
Migrating int64 `json:"migrating" xorm:"migrating"`
// When false, the behavior will not change at runtime
diff --git a/pkg/storage/legacysql/dualwrite/utils.go b/pkg/storage/legacysql/dualwrite/utils.go
new file mode 100644
index 00000000000..27832aecc0d
--- /dev/null
+++ b/pkg/storage/legacysql/dualwrite/utils.go
@@ -0,0 +1,18 @@
+package dualwrite
+
+import (
+ "golang.org/x/net/context"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ dashboard "github.com/grafana/grafana/pkg/apis/dashboard"
+ folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+)
+
+func IsReadingLegacyDashboardsAndFolders(ctx context.Context, svc Service) bool {
+ f := svc.ReadFromUnified(ctx, folders.FolderResourceInfo.GroupResource())
+ d := svc.ReadFromUnified(ctx, schema.GroupResource{
+ Group: dashboard.GROUP,
+ Resource: dashboard.DASHBOARD_RESOURCE,
+ })
+ return !(f && d)
+}
diff --git a/pkg/storage/unified/apistore/go.mod b/pkg/storage/unified/apistore/go.mod
index 9ddbc56692e..5caac733721 100644
--- a/pkg/storage/unified/apistore/go.mod
+++ b/pkg/storage/unified/apistore/go.mod
@@ -45,13 +45,16 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.2 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect
+ github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
+ github.com/Masterminds/semver v1.5.0 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
+ github.com/NYTimes/gziphandler v1.1.1 // indirect
github.com/ProtonMail/go-crypto v1.1.5 // indirect
github.com/RoaringBitmap/roaring v1.9.3 // indirect
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // indirect
@@ -84,6 +87,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
github.com/aws/smithy-go v1.20.3 // indirect
+ github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.12.0 // indirect
@@ -106,7 +110,9 @@ require (
github.com/blevesearch/zapx/v14 v14.3.10 // indirect
github.com/blevesearch/zapx/v15 v15.3.16 // indirect
github.com/blevesearch/zapx/v16 v16.1.8 // indirect
+ github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
github.com/bufbuild/protocompile v0.4.0 // indirect
+ github.com/buger/jsonparser v1.1.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cheekybits/genny v1.0.0 // indirect
@@ -116,6 +122,8 @@ require (
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/dennwc/varint v1.0.0 // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/dlmiddlecote/sqlstats v1.0.2 // indirect
github.com/docker/go-units v0.5.0 // indirect
@@ -136,8 +144,10 @@ require (
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gchaincl/sqlhooks v1.3.0 // indirect
github.com/getkin/kin-openapi v0.129.0 // indirect
+ github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/go-kit/log v0.2.1 // indirect
+ github.com/go-ldap/ldap/v3 v3.4.4 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -151,6 +161,7 @@ require (
github.com/go-openapi/strfmt v0.23.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-openapi/validate v0.24.0 // indirect
+ github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/gobwas/glob v0.2.3 // indirect
@@ -159,10 +170,12 @@ require (
github.com/gogo/googleapis v1.4.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gogo/status v1.1.1 // indirect
+ github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang-migrate/migrate/v4 v4.7.0 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+ github.com/golang/mock v1.7.0-rc.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/btree v1.1.3 // indirect
@@ -177,6 +190,7 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/gorilla/mux v1.8.1 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
github.com/grafana/alerting v0.0.0-20250207161551-04c87cf39038 // indirect
github.com/grafana/authlib v0.0.0-20250206063954-bf4600a17569 // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
@@ -185,8 +199,12 @@ require (
github.com/grafana/grafana-aws-sdk v0.31.5 // indirect
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6 // indirect
github.com/grafana/grafana-plugin-sdk-go v0.265.0 // indirect
+ github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d // indirect
+ github.com/grafana/grafana/pkg/promlib v0.0.8 // indirect
+ github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d // indirect
github.com/grafana/otel-profiling-go v0.5.1 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect
+ github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
github.com/grafana/sqlds/v4 v4.1.3 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect
@@ -207,6 +225,7 @@ require (
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.2 // indirect
@@ -231,6 +250,7 @@ require (
github.com/magefile/mage v1.15.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 // indirect
github.com/mattetti/filebuffer v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -252,12 +272,14 @@ require (
github.com/mithrandie/go-file/v2 v2.1.0 // indirect
github.com/mithrandie/go-text v1.6.0 // indirect
github.com/mithrandie/ternary v1.1.1 // indirect
+ github.com/moby/spdystream v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
+ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/natefinch/wrap v0.2.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80 // indirect
@@ -286,6 +308,7 @@ require (
github.com/prometheus/common/sigv4 v0.1.0 // indirect
github.com/prometheus/exporter-toolkit v0.13.2 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
+ github.com/prometheus/prometheus v0.301.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/cors v1.11.1 // indirect
@@ -298,7 +321,6 @@ require (
github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
- github.com/smartystreets/goconvey v1.6.4 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
@@ -316,6 +338,7 @@ require (
github.com/unknwon/com v1.0.1 // indirect
github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a // indirect
github.com/urfave/cli v1.22.16 // indirect
+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.etcd.io/bbolt v1.3.11 // indirect
@@ -365,11 +388,14 @@ require (
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/mail.v2 v2.3.1 // indirect
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.32.1 // indirect
k8s.io/component-base v0.32.1 // indirect
+ k8s.io/kms v0.32.1 // indirect
+ k8s.io/kube-aggregator v0.32.0 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
diff --git a/pkg/storage/unified/apistore/go.sum b/pkg/storage/unified/apistore/go.sum
index 19044e937df..b6e056cb978 100644
--- a/pkg/storage/unified/apistore/go.sum
+++ b/pkg/storage/unified/apistore/go.sum
@@ -136,6 +136,8 @@ github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJ
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/at-wat/mqtt-go v0.19.4 h1:R2cbCU7O5PHQ38unbe1Y51ncG3KsFEJV6QeipDoqdLQ=
@@ -184,6 +186,8 @@ github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
+github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps=
+github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@@ -341,6 +345,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.m
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM=
github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4=
+github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U=
+github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
@@ -385,6 +391,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
+github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=
github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w=
@@ -547,7 +555,6 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
@@ -803,6 +810,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
+github.com/mocktools/go-smtp-mock/v2 v2.3.1 h1:wq75NDSsOy5oHo/gEQQT0fRRaYKRqr1IdkjhIPXxagM=
+github.com/mocktools/go-smtp-mock/v2 v2.3.1/go.mod h1:h9AOf/IXLSU2m/1u4zsjtOM/WddPwdOUBz56dV9f81M=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -827,6 +836,8 @@ github.com/natefinch/wrap v0.2.0 h1:IXzc/pw5KqxJv55gV0lSOcKHYuEZPGbQrOOXr/bamRk=
github.com/natefinch/wrap v0.2.0/go.mod h1:6gMHlAl12DwYEfKP3TkuykYUfLSEAvHw67itm4/KAS8=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80 h1:nZspmSkneBbtxU9TopEAE0CY+SBJLxO8LPUlw2vG4pU=
github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80/go.mod h1:7tFDb+Y51LcDpn26GccuUgQXUk6t0CXZsivKjyimYX8=
github.com/oasdiff/yaml3 v0.0.0-20241210130736-a94c01f36349 h1:t05Ww3DxZutOqbMN+7OIuqDwXbhl32HiZGpLy26BAPc=
@@ -840,8 +851,9 @@ github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
+github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
@@ -934,6 +946,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/prometheus/prometheus v0.301.0 h1:0z8dgegmILivNomCd79RKvVkIols8vBGPKmcIBc7OyY=
github.com/prometheus/prometheus v0.301.0/go.mod h1:BJLjWCKNfRfjp7Q48DrAjARnCi7GhfUVvUFEAWTssZM=
+github.com/prometheus/sigv4 v0.1.0 h1:FgxH+m1qf9dGQ4w8Dd6VkthmpFQfGTzUeavMoQeG1LA=
+github.com/prometheus/sigv4 v0.1.0/go.mod h1:doosPW9dOitMzYe2I2BN0jZqUuBrGPbXrNsTScN18iU=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
@@ -973,7 +987,6 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
-github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY=
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
@@ -1042,7 +1055,6 @@ github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a/go.mod h1:1xEUf2abjfP9
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ=
github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
-github.com/wk8/go-ordered-map v1.0.0 h1:BV7z+2PaK8LTSd/mWgY12HyMAo5CEgkHqbkVq2thqr8=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -1058,6 +1070,7 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
@@ -1153,6 +1166,7 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
@@ -1190,6 +1204,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -1235,6 +1250,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
@@ -1262,6 +1279,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
@@ -1317,6 +1335,7 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1369,7 +1388,6 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190424220101-1e8e1cfdf96b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190425222832-ad9eeb80039a/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
@@ -1410,6 +1428,7 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
@@ -1547,6 +1566,7 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/src-d/go-errors.v1 v1.0.0 h1:cooGdZnCjYbeS1zb1s6pVAAimTdKceRrpn7aKOnNIfc=
gopkg.in/src-d/go-errors.v1 v1.0.0/go.mod h1:q1cBlomlw2FnDBDNGlnh6X0jPihy+QxZfMMNxPCbdYg=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/pkg/storage/unified/apistore/prepare_test.go b/pkg/storage/unified/apistore/prepare_test.go
index 330c2791cfa..d24b31a5a91 100644
--- a/pkg/storage/unified/apistore/prepare_test.go
+++ b/pkg/storage/unified/apistore/prepare_test.go
@@ -13,9 +13,14 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/exp/rand"
"k8s.io/apimachinery/pkg/api/apitesting"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apiserver/pkg/storage"
)
+var scheme = runtime.NewScheme()
+var codecs = serializer.NewCodecFactory(scheme)
+
func TestPrepareObjectForStorage(t *testing.T) {
_ = v0alpha1.AddToScheme(scheme)
node, err := snowflake.NewNode(rand.Int63n(1024))
diff --git a/pkg/storage/unified/apistore/store_test.go b/pkg/storage/unified/apistore/store_test.go
index 81a3b3044d4..ec1013de44a 100644
--- a/pkg/storage/unified/apistore/store_test.go
+++ b/pkg/storage/unified/apistore/store_test.go
@@ -3,7 +3,7 @@
// Provenance-includes-license: Apache-2.0
// Provenance-includes-copyright: The Kubernetes Authors.
-package apistore
+package apistore_test
import (
"context"
diff --git a/pkg/storage/unified/apistore/util.go b/pkg/storage/unified/apistore/util.go
index fb95ed08020..e41a50b5efa 100644
--- a/pkg/storage/unified/apistore/util.go
+++ b/pkg/storage/unified/apistore/util.go
@@ -9,7 +9,6 @@ import (
"bytes"
"fmt"
"strconv"
- "strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -19,7 +18,6 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/utils"
- grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
@@ -128,41 +126,3 @@ func isUnchanged(codec runtime.Codec, obj runtime.Object, newObj runtime.Object)
return bytes.Equal(buf.Bytes(), newBuf.Bytes()), nil
}
-
-func testKeyParser(val string) (*resource.ResourceKey, error) {
- k, err := grafanaregistry.ParseKey(val)
- if err != nil {
- if strings.HasPrefix(val, "pods/") {
- parts := strings.Split(val, "/")
- if len(parts) == 2 {
- err = nil
- k = &grafanaregistry.Key{
- Resource: parts[0], // pods
- Name: parts[1],
- }
- } else if len(parts) == 3 {
- err = nil
- k = &grafanaregistry.Key{
- Resource: parts[0], // pods
- Namespace: parts[1],
- Name: parts[2],
- }
- }
- }
- }
- if err != nil {
- return nil, err
- }
- if k.Group == "" {
- k.Group = "example.apiserver.k8s.io"
- }
- if k.Resource == "" {
- return nil, apierrors.NewInternalError(fmt.Errorf("missing resource in request"))
- }
- return &resource.ResourceKey{
- Namespace: k.Namespace,
- Group: k.Group,
- Resource: k.Resource,
- Name: k.Name,
- }, err
-}
diff --git a/pkg/storage/unified/apistore/watcher_test.go b/pkg/storage/unified/apistore/watcher_test.go
index 8a8b278577f..5b0842fa985 100644
--- a/pkg/storage/unified/apistore/watcher_test.go
+++ b/pkg/storage/unified/apistore/watcher_test.go
@@ -3,11 +3,13 @@
// Provenance-includes-license: Apache-2.0
// Provenance-includes-copyright: The Kubernetes Authors.
-package apistore
+package apistore_test
import (
"context"
+ "fmt"
"os"
+ "strings"
"testing"
"time"
@@ -16,6 +18,7 @@ import (
"gocloud.dev/blob/fileblob"
"gocloud.dev/blob/memblob"
"k8s.io/apimachinery/pkg/api/apitesting"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@@ -28,9 +31,11 @@ import (
"k8s.io/apiserver/pkg/storage/storagebackend"
"k8s.io/apiserver/pkg/storage/storagebackend/factory"
+ grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
storagetesting "github.com/grafana/grafana/pkg/apiserver/storage/testing"
infraDB "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/sql"
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
@@ -160,7 +165,7 @@ func testSetup(t testing.TB, opts ...setupOption) (context.Context, storage.Inte
client := resource.NewLocalResourceClient(server)
config := storagebackend.NewDefaultConfig(setupOpts.prefix, setupOpts.codec)
- store, destroyFunc, err := NewStorage(
+ store, destroyFunc, err := apistore.NewStorage(
config.ForResource(setupOpts.groupResource),
client,
func(obj runtime.Object) (string, error) {
@@ -176,7 +181,7 @@ func testSetup(t testing.TB, opts ...setupOption) (context.Context, storage.Inte
storage.DefaultNamespaceScopedAttr,
make(map[string]storage.IndexerFunc, 0),
nil,
- StorageOptions{},
+ apistore.StorageOptions{},
)
if err != nil {
return nil, nil, nil, err
@@ -371,3 +376,41 @@ func newPod() runtime.Object {
func newPodList() runtime.Object {
return &example.PodList{}
}
+
+func testKeyParser(val string) (*resource.ResourceKey, error) {
+ k, err := grafanaregistry.ParseKey(val)
+ if err != nil {
+ if strings.HasPrefix(val, "pods/") {
+ parts := strings.Split(val, "/")
+ if len(parts) == 2 {
+ err = nil
+ k = &grafanaregistry.Key{
+ Resource: parts[0], // pods
+ Name: parts[1],
+ }
+ } else if len(parts) == 3 {
+ err = nil
+ k = &grafanaregistry.Key{
+ Resource: parts[0], // pods
+ Namespace: parts[1],
+ Name: parts[2],
+ }
+ }
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ if k.Group == "" {
+ k.Group = "example.apiserver.k8s.io"
+ }
+ if k.Resource == "" {
+ return nil, apierrors.NewInternalError(fmt.Errorf("missing resource in request"))
+ }
+ return &resource.ResourceKey{
+ Namespace: k.Namespace,
+ Group: k.Group,
+ Resource: k.Resource,
+ Name: k.Name,
+ }, err
+}
diff --git a/pkg/storage/unified/resource/bleve_index_metrics.go b/pkg/storage/unified/resource/bleve_index_metrics.go
index caa0499bcb8..a15f36be543 100644
--- a/pkg/storage/unified/resource/bleve_index_metrics.go
+++ b/pkg/storage/unified/resource/bleve_index_metrics.go
@@ -11,8 +11,10 @@ import (
)
var (
- onceIndex sync.Once
- IndexMetrics *BleveIndexMetrics
+ onceIndex sync.Once
+ onceSprinkles sync.Once
+ IndexMetrics *BleveIndexMetrics
+ SprinklesIndexMetrics *SprinklesMetrics
)
type BleveIndexMetrics struct {
@@ -28,8 +30,30 @@ type BleveIndexMetrics struct {
IndexTenants *prometheus.CounterVec
}
+type SprinklesMetrics struct {
+ SprinklesLatency prometheus.Histogram
+}
+
var IndexCreationBuckets = []float64{1, 5, 10, 25, 50, 75, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000}
+func NewSprinklesMetrics() *SprinklesMetrics {
+ onceSprinkles.Do(func() {
+ SprinklesIndexMetrics = &SprinklesMetrics{
+ SprinklesLatency: prometheus.NewHistogram(prometheus.HistogramOpts{
+ Namespace: "index_server",
+ Name: "sprinkles_latency_seconds",
+ Help: "Time (in seconds) it takes until sprinkles are fetched",
+ Buckets: instrument.DefBuckets,
+ NativeHistogramBucketFactor: 1.1, // enable native histograms
+ NativeHistogramMaxBucketNumber: 160,
+ NativeHistogramMinResetDuration: time.Hour,
+ }),
+ }
+ })
+
+ return SprinklesIndexMetrics
+}
+
func NewIndexMetrics(indexDir string, searchBackend SearchBackend) *BleveIndexMetrics {
onceIndex.Do(func() {
IndexMetrics = &BleveIndexMetrics{
@@ -79,6 +103,14 @@ func NewIndexMetrics(indexDir string, searchBackend SearchBackend) *BleveIndexMe
return IndexMetrics
}
+func (s *SprinklesMetrics) Collect(ch chan<- prometheus.Metric) {
+ s.SprinklesLatency.Collect(ch)
+}
+
+func (s *SprinklesMetrics) Describe(ch chan<- *prometheus.Desc) {
+ s.SprinklesLatency.Describe(ch)
+}
+
func (s *BleveIndexMetrics) Collect(ch chan<- prometheus.Metric) {
s.IndexLatency.Collect(ch)
s.IndexCreationTime.Collect(ch)
diff --git a/pkg/storage/unified/resource/resource.pb.go b/pkg/storage/unified/resource/resource.pb.go
index ee8801f361b..dc6593d385f 100644
--- a/pkg/storage/unified/resource/resource.pb.go
+++ b/pkg/storage/unified/resource/resource.pb.go
@@ -2016,6 +2016,7 @@ type ResourceSearchRequest struct {
Explain bool `protobuf:"varint,9,opt,name=explain,proto3" json:"explain,omitempty"`
IsDeleted bool `protobuf:"varint,10,opt,name=is_deleted,json=isDeleted,proto3" json:"is_deleted,omitempty"`
Page int64 `protobuf:"varint,11,opt,name=page,proto3" json:"page,omitempty"`
+ Permission int64 `protobuf:"varint,12,opt,name=permission,proto3" json:"permission,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -2127,6 +2128,13 @@ func (x *ResourceSearchRequest) GetPage() int64 {
return 0
}
+func (x *ResourceSearchRequest) GetPermission() int64 {
+ if x != nil {
+ return x.Permission
+ }
+ return 0
+}
+
type ResourceSearchResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Error details
@@ -4238,8 +4246,8 @@ var file_resource_proto_rawDesc = string([]byte{
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08,
0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e,
- 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xee,
- 0x04, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63,
+ 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x8e,
+ 0x05, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63,
0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69,
0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73,
@@ -4265,7 +4273,9 @@ var file_resource_proto_rawDesc = string([]byte{
0x6c, 0x61, 0x69, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x73, 0x5f, 0x64, 0x65, 0x6c, 0x65, 0x74,
0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x65, 0x6c, 0x65,
0x74, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x67, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28,
- 0x03, 0x52, 0x04, 0x70, 0x61, 0x67, 0x65, 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, 0x72, 0x74, 0x12,
+ 0x03, 0x52, 0x04, 0x70, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69,
+ 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x65, 0x72,
+ 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, 0x72, 0x74, 0x12,
0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18, 0x02, 0x20,
0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x1a, 0x33, 0x0a, 0x05, 0x46, 0x61, 0x63,
diff --git a/pkg/storage/unified/resource/resource.proto b/pkg/storage/unified/resource/resource.proto
index f16f194d360..c11ed29f259 100644
--- a/pkg/storage/unified/resource/resource.proto
+++ b/pkg/storage/unified/resource/resource.proto
@@ -457,6 +457,8 @@ message ResourceSearchRequest {
bool is_deleted = 10;
int64 page = 11;
+
+ int64 permission = 12;
}
message ResourceSearchResponse {
diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go
index bf9748cf5bc..2b76bb97529 100644
--- a/pkg/storage/unified/search/bleve.go
+++ b/pkg/storage/unified/search/bleve.go
@@ -18,6 +18,7 @@ import (
"github.com/blevesearch/bleve/v2/search/query"
bleveSearch "github.com/blevesearch/bleve/v2/search/searcher"
index "github.com/blevesearch/bleve_index_api"
+ "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"go.opentelemetry.io/otel/trace"
"k8s.io/apimachinery/pkg/selection"
@@ -611,11 +612,16 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resource.Res
if !ok {
return nil, resource.AsErrorResult(fmt.Errorf("missing auth info"))
}
+ verb := utils.VerbList
+ if req.Permission == int64(dashboardaccess.PERMISSION_EDIT) {
+ verb = utils.VerbPatch
+ }
+
checker, err := access.Compile(ctx, auth, authlib.ListRequest{
Namespace: b.key.Namespace,
Group: b.key.Group,
Resource: b.key.Resource,
- Verb: utils.VerbList,
+ Verb: verb,
})
if err != nil {
return nil, resource.AsErrorResult(err)
diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go
index 2c1f1ffd602..b2cade286f5 100644
--- a/pkg/storage/unified/sql/server.go
+++ b/pkg/storage/unified/sql/server.go
@@ -83,10 +83,15 @@ func NewResourceServer(ctx context.Context, db infraDB.DB, cfg *setting.Cfg,
InitMinCount: cfg.IndexMinCount,
}
+ // Register indexer metrics
err = reg.Register(resource.NewIndexMetrics(cfg.IndexPath, opts.Search.Backend))
if err != nil {
slog.Warn("Failed to register indexer metrics", "error", err)
}
+ err = reg.Register(resource.NewSprinklesMetrics())
+ if err != nil {
+ slog.Warn("Failed to register sprinkles metrics", "error", err)
+ }
}
rs, err := resource.NewResourceServer(opts)
diff --git a/pkg/tests/alertmanager/alertmanager_scenario.go b/pkg/tests/alertmanager/alertmanager_scenario.go
new file mode 100644
index 00000000000..719cccef4ab
--- /dev/null
+++ b/pkg/tests/alertmanager/alertmanager_scenario.go
@@ -0,0 +1,386 @@
+package alertmanager
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/grafana/e2e"
+ gapi "github.com/grafana/grafana-api-golang-client"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ defaultNetworkName = "e2e-grafana-am"
+)
+
+type AlertRuleConfig struct {
+ PendingPeriod string
+ GroupEvaluationIntervalSeconds int64
+}
+
+type NotificationPolicyCfg struct {
+ GroupWait string
+ GroupInterval string
+ RepeatInterval string
+}
+
+type ProvisionCfg struct {
+ AlertRuleConfig
+ NotificationPolicyCfg
+}
+
+// AlertmanagerScenario is a helper for writing tests which require some number of AM
+// configured to communicate with some number of Grafana instances.
+type AlertmanagerScenario struct {
+ *e2e.Scenario
+
+ Grafanas map[string]*GrafanaService
+ Webhook *WebhookService
+ Postgres *PostgresService
+ Loki *LokiService
+}
+
+func NewAlertmanagerScenario() (*AlertmanagerScenario, error) {
+ s, err := e2e.NewScenario(getNetworkName())
+ if err != nil {
+ return nil, err
+ }
+
+ return &AlertmanagerScenario{
+ Scenario: s,
+ Grafanas: make(map[string]*GrafanaService),
+ }, nil
+}
+
+// Setup starts a Grafana AM cluster of size n and all required dependencies
+func (s *AlertmanagerScenario) Start(t *testing.T, n int, peerTimeout string, stopOnExtraDedup bool) {
+ is := getInstances(n)
+ ips := mapInstancePeers(is)
+
+ // start dependencies in one go
+ require.NoError(
+ t,
+ s.StartAndWaitReady([]e2e.Service{
+ s.NewWebhookService("webhook"),
+ s.NewLokiService("loki"),
+ s.NewPostgresService("postgres"),
+ }...),
+ )
+
+ for i, ps := range ips {
+ require.NoError(t, s.StartAndWaitReady(s.NewGrafanaService(i, ps, peerTimeout, stopOnExtraDedup)))
+ }
+
+ // wait for instances to come online and cluster to be properly configured
+ time.Sleep(30 * time.Second)
+}
+
+// Provision provisions all required resources for the test
+func (s *AlertmanagerScenario) Provision(t *testing.T, cfg ProvisionCfg) { //}*GrafanaClient {
+ c, err := s.NewGrafanaClient("grafana-1", 1)
+ require.NoError(t, err)
+
+ dsUID := "integration-testdata"
+
+ // setup resources
+ _, err = c.NewDataSource(&gapi.DataSource{
+ Name: "grafana-testdata-datasource",
+ Type: "grafana-testdata-datasource",
+ Access: "proxy",
+ UID: dsUID,
+ })
+ require.NoError(t, err)
+
+ // setup loki for state history
+ _, err = c.NewDataSource(&gapi.DataSource{
+ Name: "loki",
+ Type: "loki",
+ URL: "http://loki:3100",
+ Access: "proxy",
+ })
+ require.NoError(t, err)
+
+ _, err = c.NewContactPoint(&gapi.ContactPoint{
+ Name: "webhook",
+ Type: "webhook",
+ Settings: map[string]any{
+ "url": "http://webhook:8080/notify",
+ },
+ })
+ require.NoError(t, err)
+
+ require.NoError(t, c.SetNotificationPolicyTree(&gapi.NotificationPolicyTree{
+ Receiver: "webhook",
+ GroupWait: cfg.GroupWait,
+ GroupInterval: cfg.GroupInterval,
+ RepeatInterval: cfg.RepeatInterval,
+ }))
+
+ f, err := c.NewFolder("integration_test")
+ require.NoError(t, err)
+
+ r := &gapi.AlertRule{
+ Title: "integration rule",
+ Condition: "C",
+ FolderUID: f.UID,
+ ExecErrState: gapi.ErrError,
+ NoDataState: gapi.NoData,
+ For: cfg.PendingPeriod,
+ RuleGroup: "test",
+ Data: []*gapi.AlertQuery{
+ {
+ RefID: "A",
+ RelativeTimeRange: gapi.RelativeTimeRange{
+ From: 600,
+ To: 0,
+ },
+ DatasourceUID: dsUID,
+ Model: json.RawMessage(fmt.Sprintf(`{
+ "refId":"A",
+ "datasource": {
+ "type": "grafana-testdata-datasource",
+ "uid": "%s"
+ },
+ "hide":false,
+ "range":false,
+ "instant":true,
+ "intervalMs":1000,
+ "maxDataPoints":43200,
+ "pulseWave": {
+ "offCount": 6,
+ "offValue": 0,
+ "onCount": 10,
+ "onValue": 10,
+ "timeStep": 10
+ },
+ "refId": "A",
+ "scenarioId": "predictable_pulse",
+ "seriesCount": 1
+ }`, dsUID)),
+ },
+ {
+ RefID: "B",
+ RelativeTimeRange: gapi.RelativeTimeRange{
+ From: 0,
+ To: 0,
+ },
+ DatasourceUID: "__expr__",
+ Model: json.RawMessage(`{
+ "conditions": [
+ {
+ "evaluator": {
+ "params": [
+ 0,
+ 0
+ ],
+ "type": "gt"
+ },
+ "operator": {
+ "type": "and"
+ },
+ "query": {
+ "params": []
+ },
+ "reducer": {
+ "params": [],
+ "type": "avg"
+ },
+ "type": "query"
+ }
+ ],
+ "datasource": {
+ "name": "Expression",
+ "type": "__expr__",
+ "uid": "__expr__"
+ },
+ "expression": "A",
+ "intervalMs": 1000,
+ "maxDataPoints": 43200,
+ "reducer": "last",
+ "refId": "B",
+ "type": "reduce"
+ }`),
+ },
+ {
+ RefID: "C",
+ RelativeTimeRange: gapi.RelativeTimeRange{
+ From: 0,
+ To: 0,
+ },
+ DatasourceUID: "__expr__",
+ Model: json.RawMessage(`{
+ "conditions": [
+ {
+ "evaluator": {
+ "params": [
+ 0,
+ 0
+ ],
+ "type": "gt"
+ },
+ "operator": {
+ "type": "and"
+ },
+ "query": {
+ "params": [
+ "B"
+ ]
+ },
+ "reducer": {
+ "params": [],
+ "type": "last"
+ },
+ "type": "query"
+ }
+ ],
+ "datasource": {
+ "type": "__expr__",
+ "uid": "__expr__"
+ },
+ "hide": false,
+ "isPaused": false,
+ "intervalMs": 1000,
+ "maxDataPoints": 43200,
+ "refId": "C",
+ "expression": "B",
+ "type": "threshold"
+ }`),
+ },
+ },
+ }
+ _, err = c.NewAlertRule(r)
+ require.NoError(t, err)
+
+ require.NoError(t, c.SetAlertRuleGroup(gapi.RuleGroup{
+ Title: "test",
+ FolderUID: f.UID,
+ Interval: cfg.GroupEvaluationIntervalSeconds,
+ Rules: []gapi.AlertRule{*r},
+ }))
+}
+
+// NewGrafanaService creates a new Grafana instance.
+func (s *AlertmanagerScenario) NewGrafanaService(name string, peers []string, peerTimeout string, stopOnExtraDedup bool) *GrafanaService {
+ flags := map[string]string{}
+
+ ft := []string{
+ "alertStateHistoryLokiSecondary",
+ "alertStateHistoryLokiPrimary",
+ "alertStateHistoryLokiOnly",
+ "alertingAlertmanagerExtraDedupStage",
+ }
+ if stopOnExtraDedup {
+ ft = append(ft, "alertingAlertmanagerExtraDedupStageStopPipeline")
+ }
+ envVars := map[string]string{
+ //"GF_LOG_MODE": "file", // disable console logging
+ "GF_LOG_LEVEL": "warn",
+ "GF_FEATURE_TOGGLES_ENABLE": strings.Join(ft, ","),
+ "GF_UNIFIED_ALERTING_ENABLED": "true",
+ "GF_UNIFIED_ALERTING_EXECUTE_ALERTS": "true",
+ "GF_UNIFIED_ALERTING_HA_PEER_TIMEOUT": peerTimeout,
+ "GF_UNIFIED_ALERTING_HA_RECONNECT_TIMEOUT": "2m",
+ "GF_UNIFIED_ALERTING_HA_LISTEN_ADDRESS": ":9094",
+ "GF_UNIFIED_ALERTING_HA_PEERS": strings.Join(peers, ","),
+ "GF_UNIFIED_ALERTING_STATE_HISTORY_ENABLED": "true",
+ "GF_UNIFIED_ALERTING_STATE_HISTORY_BACKEND": "loki",
+ "GF_UNIFIED_ALERTING_STATE_HISTORY_LOKI_REMOTE_URL": "http://loki:3100",
+ "GF_DATABASE_TYPE": "postgres",
+ "GF_DATABASE_HOST": "postgres:5432",
+ "GF_DATABASE_NAME": "grafana",
+ "GF_DATABASE_USER": "postgres",
+ "GF_DATABASE_PASSWORD": "password",
+ "GF_DATABASE_SSL_MODE": "disable",
+ }
+
+ g := NewGrafanaService(name, flags, envVars)
+
+ s.Grafanas[name] = g
+ return g
+}
+
+// NewGrafanaService creates a new Grafana API client for the requested instance.
+func (s *AlertmanagerScenario) NewGrafanaClient(grafanaName string, orgID int64) (*GrafanaClient, error) {
+ g, ok := s.Grafanas[grafanaName]
+ if !ok {
+ return nil, fmt.Errorf("unknown grafana instance: %s", grafanaName)
+ }
+
+ return NewGrafanaClient(g.HTTPEndpoint(), orgID)
+}
+
+func (s *AlertmanagerScenario) NewWebhookClient() (*WebhookClient, error) {
+ return NewWebhookClient("http://" + s.Webhook.HTTPEndpoint())
+}
+
+func (s *AlertmanagerScenario) NewWebhookService(name string) *WebhookService {
+ ws := NewWebhookService(name, nil, nil)
+ s.Webhook = ws
+
+ return ws
+}
+
+func (s *AlertmanagerScenario) NewLokiService(name string) *LokiService {
+ ls := NewLokiService(name, map[string]string{"--config.file": "/etc/loki/local-config.yaml"}, nil)
+ s.Loki = ls
+
+ return ls
+}
+
+func (s *AlertmanagerScenario) NewPostgresService(name string) *PostgresService {
+ ps := NewPostgresService(name, map[string]string{"POSTGRES_PASSWORD": "password", "POSTGRES_DB": "grafana"})
+ s.Postgres = ps
+
+ return ps
+}
+
+func (s *AlertmanagerScenario) NewLokiClient() (*LokiClient, error) {
+ return NewLokiClient("http://" + s.Loki.HTTPEndpoint())
+}
+
+func getNetworkName() string {
+ // If the E2E_NETWORK_NAME is set, use that for the network name.
+ // Otherwise, return the default network name.
+ if os.Getenv("E2E_NETWORK_NAME") != "" {
+ return os.Getenv("E2E_NETWORK_NAME")
+ }
+
+ return defaultNetworkName
+}
+
+func getInstances(n int) []string {
+ is := make([]string, n)
+
+ for i := 0; i < n; i++ {
+ is[i] = "grafana-" + strconv.Itoa(i+1)
+ }
+
+ return is
+}
+
+func getPeers(i string, is []string) []string {
+ peers := make([]string, 0, len(is)-1)
+
+ for _, p := range is {
+ if p != i {
+ peers = append(peers, p+":9094")
+ }
+ }
+
+ return peers
+}
+
+func mapInstancePeers(is []string) map[string][]string {
+ mIs := make(map[string][]string, len(is))
+
+ for _, i := range is {
+ mIs[i] = getPeers(i, is)
+ }
+
+ return mIs
+}
diff --git a/pkg/tests/alertmanager/alertmanager_test.go b/pkg/tests/alertmanager/alertmanager_test.go
new file mode 100644
index 00000000000..269e53075e2
--- /dev/null
+++ b/pkg/tests/alertmanager/alertmanager_test.go
@@ -0,0 +1,98 @@
+package alertmanager
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAlertmanagerIntegration_ExtraDedupStage(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping integration test")
+ }
+
+ t.Run("assert no flapping alerts when stopOnExtraDedup is enabled", func(t *testing.T) {
+ t.Skip("skipping flaky test")
+ s, err := NewAlertmanagerScenario()
+ require.NoError(t, err)
+ defer s.Close()
+
+ s.Start(t, 20, "15s", true)
+ s.Provision(t, ProvisionCfg{
+ AlertRuleConfig: AlertRuleConfig{
+ PendingPeriod: "30s",
+ GroupEvaluationIntervalSeconds: 10,
+ },
+ NotificationPolicyCfg: NotificationPolicyCfg{
+ GroupWait: "30s",
+ GroupInterval: "1m",
+ RepeatInterval: "30m",
+ },
+ })
+
+ wc, err := s.NewWebhookClient()
+ require.NoError(t, err)
+
+ lc, err := s.NewLokiClient()
+ require.NoError(t, err)
+
+ // notifications only start arriving after 2 to 3 minutes so we wait for that
+ time.Sleep(time.Minute * 2)
+
+ timeout := time.After(5 * time.Minute)
+ ticker := time.NewTicker(5 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ nr, err := wc.GetNotifications()
+ if err != nil {
+ t.Logf("failed to get alert notifications: %v\n", err)
+ continue
+ }
+
+ // get the latest state for the alert from loki
+ st, err := lc.GetCurrentAlertState()
+ if err != nil {
+ t.Logf("failed to get alert state: %v\n", err)
+ continue
+ }
+
+ // if the last state is not normal, ignore
+ // we might be missing other cases of flapping notifications but for now we are only interested in this one
+ // (alerting notification when state is already normal)
+ if st.State != AlertStateNormal {
+ continue
+ }
+
+ // history is ordered - fetch the first notification that is after the last state change
+ var i int
+ for i = range nr.History {
+ if nr.History[i].TimeNow.After(st.Timestamp) {
+ break
+ }
+ }
+
+ // if all notifications are from before the last state change, we can wait a bit more
+ if nr.History[i].TimeNow.Before(st.Timestamp) {
+ continue
+ }
+
+ // for all notifications after the last state change, check if there is a firing one
+ for ; i < len(nr.History); i++ {
+ notification := nr.History[i]
+ if notification.Status == "firing" {
+ t.Errorf("flapping notifications - got firing notification when alert was resolved, state = %#v, notification = %#v", st, notification)
+ t.FailNow()
+ }
+ }
+
+ case <-timeout:
+ // if after the timeout there are no such cases, we assume there are no flapping notifications
+ return
+ }
+ }
+ })
+}
diff --git a/pkg/tests/alertmanager/grafana.go b/pkg/tests/alertmanager/grafana.go
new file mode 100644
index 00000000000..7d67ffdf53c
--- /dev/null
+++ b/pkg/tests/alertmanager/grafana.go
@@ -0,0 +1,75 @@
+package alertmanager
+
+import (
+ _ "embed"
+ "fmt"
+ "net/url"
+ "os"
+
+ "github.com/grafana/e2e"
+ gapi "github.com/grafana/grafana-api-golang-client"
+)
+
+const (
+ grafanaBinary = "/run.sh"
+ grafanaHTTPPort = 3000
+)
+
+// GetDefaultImage returns the Docker image to use to run the Grafana..
+func GetGrafanaImage() string {
+ if img := os.Getenv("GRAFANA_IMAGE"); img != "" {
+ return img
+ }
+
+ if version := os.Getenv("GRAFANA_VERSION"); version != "" {
+ return "grafana/grafana-enterprise-dev:" + version
+ }
+
+ panic("Provide GRAFANA_VERSION or GRAFANA_IMAGE")
+}
+
+type GrafanaService struct {
+ *e2e.HTTPService
+}
+
+func NewGrafanaService(name string, flags, envVars map[string]string) *GrafanaService {
+ svc := &GrafanaService{
+ HTTPService: e2e.NewHTTPService(
+ name,
+ GetGrafanaImage(),
+ e2e.NewCommandWithoutEntrypoint(grafanaBinary, e2e.BuildArgs(flags)...),
+ e2e.NewHTTPReadinessProbe(grafanaHTTPPort, "/ready", 200, 299),
+ grafanaHTTPPort,
+ 9094,
+ ),
+ }
+
+ svc.SetEnvVars(envVars)
+
+ return svc
+}
+
+type GrafanaClient struct {
+ *gapi.Client
+}
+
+// NewGrafanaClient creates a client for using the Grafana API. Note we don't bother
+// wrapping the client library, and just use it as-is, until we find a reason not to.
+func NewGrafanaClient(host string, orgID int64) (*GrafanaClient, error) {
+ cfg := gapi.Config{
+ BasicAuth: url.UserPassword("admin", "admin"),
+ OrgID: orgID,
+ HTTPHeaders: map[string]string{
+ "X-Disable-Provenance": "true",
+ },
+ }
+
+ client, err := gapi.New(fmt.Sprintf("http://%s/", host), cfg)
+ if err != nil {
+ return nil, err
+ }
+
+ return &GrafanaClient{
+ Client: client,
+ }, nil
+}
diff --git a/pkg/tests/alertmanager/loki.go b/pkg/tests/alertmanager/loki.go
new file mode 100644
index 00000000000..16c6fda0b90
--- /dev/null
+++ b/pkg/tests/alertmanager/loki.go
@@ -0,0 +1,152 @@
+package alertmanager
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/grafana/e2e"
+)
+
+const (
+ defaultLokiImage = "grafana/loki:latest"
+ lokiBinary = "/usr/bin/loki"
+ lokiHTTPPort = 3100
+)
+
+// GetDefaultImage returns the Docker image to use to run the Loki..
+func GetLokiImage() string {
+ if img := os.Getenv("LOKI_IMAGE"); img != "" {
+ return img
+ }
+
+ return defaultLokiImage
+}
+
+type LokiService struct {
+ *e2e.HTTPService
+}
+
+func NewLokiService(name string, flags, envVars map[string]string) *LokiService {
+ svc := &LokiService{
+ HTTPService: e2e.NewHTTPService(
+ name,
+ GetLokiImage(),
+ e2e.NewCommandWithoutEntrypoint(lokiBinary, e2e.BuildArgs(flags)...),
+ e2e.NewHTTPReadinessProbe(lokiHTTPPort, "/ready", 200, 299),
+ lokiHTTPPort,
+ ),
+ }
+
+ svc.SetEnvVars(envVars)
+
+ return svc
+}
+
+type LokiClient struct {
+ c http.Client
+ u *url.URL
+}
+
+func NewLokiClient(u string) (*LokiClient, error) {
+ pu, err := url.Parse(u)
+ if err != nil {
+ return nil, err
+ }
+
+ return &LokiClient{
+ c: http.Client{},
+ u: pu,
+ }, nil
+}
+
+type LokiQueryResponse struct {
+ Status string `json:"status"`
+ Data struct {
+ ResultType string `json:"resultType"`
+ Result []struct {
+ Stream struct {
+ Condition string `json:"condition"`
+ Current string `json:"current"`
+ DashboardUID string `json:"dashboardUID"`
+ Fingerprint string `json:"fingerprint"`
+ FolderUID string `json:"folderUID"`
+ From string `json:"from"`
+ Group string `json:"group"`
+ LabelsAlertname string `json:"labels_alertname"`
+ LabelsGrafanaFolder string `json:"labels_grafana_folder"`
+ OrgID string `json:"orgID"`
+ PanelID string `json:"panelID"`
+ Previous string `json:"previous"`
+ RuleID string `json:"ruleID"`
+ RuleTitle string `json:"ruleTitle"`
+ RuleUID string `json:"ruleUID"`
+ SchemaVersion string `json:"schemaVersion"`
+ ServiceName string `json:"service_name"`
+ ValuesB string `json:"values_B"`
+ ValuesC string `json:"values_C"`
+ } `json:"stream"`
+ Values [][]string `json:"values"`
+ } `json:"result"`
+ }
+}
+
+type AlertState string
+
+const (
+ AlertStateNormal AlertState = "Normal"
+ AlertStatePending AlertState = "Pending"
+ AlertStateAlerting AlertState = "Alerting"
+)
+
+type AlertStateResponse struct {
+ State AlertState
+ Timestamp time.Time
+}
+
+// GetCurrentAlertState fetches the current alert state from loki
+func (c *LokiClient) GetCurrentAlertState() (*AlertStateResponse, error) {
+ u := c.u.ResolveReference(&url.URL{Path: "/loki/api/v1/query_range"})
+
+ vs := url.Values{}
+ vs.Add("query", `{from="state-history"} | json`)
+ vs.Add("since", "60s")
+
+ u.RawQuery = vs.Encode()
+
+ resp, err := c.c.Get(u.String())
+ if err != nil {
+ return nil, err
+ }
+ //nolint:errcheck
+ defer resp.Body.Close()
+
+ res := LokiQueryResponse{}
+
+ if err = json.NewDecoder(resp.Body).Decode(&res); err != nil {
+ return nil, err
+ }
+
+ if res.Status != "success" {
+ return nil, fmt.Errorf("failed to query state from loki")
+ }
+
+ if len(res.Data.Result) == 0 {
+ return nil, fmt.Errorf("empty result from loki")
+ }
+
+ r := res.Data.Result[0]
+ it, err := strconv.ParseInt(r.Values[0][0], 10, 0)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse timestamp: %v", err)
+ }
+
+ return &AlertStateResponse{
+ State: AlertState(r.Stream.Current),
+ Timestamp: time.Unix(0, it),
+ }, nil
+}
diff --git a/pkg/tests/alertmanager/postgres.go b/pkg/tests/alertmanager/postgres.go
new file mode 100644
index 00000000000..3d3fe4577d9
--- /dev/null
+++ b/pkg/tests/alertmanager/postgres.go
@@ -0,0 +1,41 @@
+package alertmanager
+
+import (
+ "os"
+
+ "github.com/grafana/e2e"
+)
+
+const (
+ defaultPostgresImage = "postgres:16.4"
+ postgresHTTPPort = 5432
+)
+
+// GetDefaultImage returns the Docker image to use to run the Postgres..
+func GetPostgresImage() string {
+ if img := os.Getenv("POSTGRES_IMAGE"); img != "" {
+ return img
+ }
+
+ return defaultPostgresImage
+}
+
+type PostgresService struct {
+ *e2e.HTTPService
+}
+
+func NewPostgresService(name string, envVars map[string]string) *PostgresService {
+ svc := &PostgresService{
+ HTTPService: e2e.NewHTTPService(
+ name,
+ GetPostgresImage(),
+ nil,
+ nil,
+ postgresHTTPPort,
+ ),
+ }
+
+ svc.SetEnvVars(envVars)
+
+ return svc
+}
diff --git a/pkg/tests/alertmanager/webhook.go b/pkg/tests/alertmanager/webhook.go
new file mode 100644
index 00000000000..c90c3f229eb
--- /dev/null
+++ b/pkg/tests/alertmanager/webhook.go
@@ -0,0 +1,85 @@
+package alertmanager
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/grafana/e2e"
+)
+
+const (
+ defaultWebhookImage = "webhook-receiver"
+ webhookBinary = "/bin/main"
+ webhookHTTPPort = 8080
+)
+
+type WebhookService struct {
+ *e2e.HTTPService
+}
+
+func NewWebhookService(name string, flags, envVars map[string]string) *WebhookService {
+ svc := &WebhookService{
+ HTTPService: e2e.NewHTTPService(
+ name,
+ "webhook-receiver",
+ e2e.NewCommandWithoutEntrypoint(webhookBinary, e2e.BuildArgs(flags)...),
+ e2e.NewHTTPReadinessProbe(webhookHTTPPort, "/ready", 200, 299),
+ webhookHTTPPort),
+ }
+
+ svc.SetEnvVars(envVars)
+
+ return svc
+}
+
+type WebhookClient struct {
+ c http.Client
+ u *url.URL
+}
+
+func NewWebhookClient(u string) (*WebhookClient, error) {
+ pu, err := url.Parse(u)
+ if err != nil {
+ return nil, err
+ }
+
+ return &WebhookClient{
+ c: http.Client{},
+ u: pu,
+ }, nil
+}
+
+type GetNotificationsResponse struct {
+ Stats map[string]int `json:"stats"`
+ History []struct {
+ Status string `json:"status"`
+ TimeNow time.Time `json:"timeNow"`
+ StartsAt time.Time `json:"startsAt"`
+ Node string `json:"node"`
+ DeltaLastSeconds float64 `json:"deltaLastSeconds"`
+ DeltaStartSeconds float64 `json:"deltaStartSeconds"`
+ } `json:"history"`
+}
+
+// GetNotifications fetches notifications from the webhook server
+func (c *WebhookClient) GetNotifications() (*GetNotificationsResponse, error) {
+ u := c.u.ResolveReference(&url.URL{Path: "/notifications"})
+
+ resp, err := c.c.Get(u.String())
+ if err != nil {
+ return nil, err
+ }
+ //nolint:errcheck
+ defer resp.Body.Close()
+
+ res := GetNotificationsResponse{}
+
+ err = json.NewDecoder(resp.Body).Decode(&res)
+ if err != nil {
+ return nil, err
+ }
+
+ return &res, nil
+}
diff --git a/pkg/tests/api/dashboards/home.json b/pkg/tests/api/dashboards/home.json
index ee516adac16..08e991b5925 100644
--- a/pkg/tests/api/dashboards/home.json
+++ b/pkg/tests/api/dashboards/home.json
@@ -210,17 +210,6 @@
"1h",
"2h",
"1d"
- ],
- "time_options": [
- "5m",
- "15m",
- "1h",
- "6h",
- "12h",
- "24h",
- "2d",
- "7d",
- "30d"
]
},
"timezone": "",
diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json
index 0c0aab6a190..14a1df4d7e9 100644
--- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json
+++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json
@@ -2788,12 +2788,18 @@
"description": "The branch to use in the repository. By default, this is the main branch.",
"type": "string"
},
+ "encryptedToken": {
+ "description": "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.",
+ "type": "string",
+ "format": "byte",
+ "x-kubernetes-list-type": "atomic"
+ },
"generateDashboardPreviews": {
"description": "Whether we should show dashboard previews for pull requests By default, this is false (i.e. we will not create previews).",
"type": "boolean"
},
"token": {
- "description": "Token for accessing the repository.",
+ "description": "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.",
"type": "string"
},
"url": {
@@ -3026,6 +3032,10 @@
"resource": {
"type": "string"
},
+ "total": {
+ "type": "integer",
+ "format": "int64"
+ },
"update": {
"type": "integer",
"format": "int64"
@@ -3298,7 +3308,7 @@
"type": "string"
},
"github": {
- "description": "The repository on GitHub. Mutually exclusive with local | s3 | github.",
+ "description": "The repository on GitHub. Mutually exclusive with local | github.",
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.GitHubRepositoryConfig"
@@ -3306,7 +3316,7 @@
]
},
"local": {
- "description": "The repository on the local file system. Mutually exclusive with local | s3 | github.",
+ "description": "The repository on the local file system. Mutually exclusive with local | github.",
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.LocalRepositoryConfig"
@@ -3318,14 +3328,6 @@
"type": "boolean",
"default": false
},
- "s3": {
- "description": "The repository in an S3 bucket. Mutually exclusive with local | s3 | github.",
- "allOf": [
- {
- "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.S3RepositoryConfig"
- }
- ]
- },
"sync": {
"description": "Sync settings -- how values are pulled from the repository into grafana",
"default": {},
@@ -3341,13 +3343,12 @@
"default": ""
},
"type": {
- "description": "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`\n - `\"s3\"`",
+ "description": "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`",
"type": "string",
"default": "",
"enum": [
"github",
- "local",
- "s3"
+ "local"
]
}
}
@@ -3444,13 +3445,12 @@
"default": ""
},
"type": {
- "description": "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`\n - `\"s3\"`",
+ "description": "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`",
"type": "string",
"default": "",
"enum": [
"github",
- "local",
- "s3"
+ "local"
]
}
}
@@ -3480,6 +3480,10 @@
"kind": {
"description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
"type": "string"
+ },
+ "legacyStorage": {
+ "description": "The backend is using legacy storage FIXME: Not sure where this should be exposed... but we need it somewhere The UI should force the onboarding workflow when this is true",
+ "type": "boolean"
}
}
},
@@ -3783,17 +3787,6 @@
}
]
},
- "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.S3RepositoryConfig": {
- "type": "object",
- "properties": {
- "bucket": {
- "type": "string"
- },
- "region": {
- "type": "string"
- }
- }
- },
"com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.SyncJobOptions": {
"type": "object",
"required": [
@@ -3993,6 +3986,10 @@
"com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.WebhookStatus": {
"type": "object",
"properties": {
+ "encryptedSecret": {
+ "type": "string",
+ "format": "byte"
+ },
"id": {
"type": "integer",
"format": "int64"
diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go
index 19e91cf7d05..7e37837e23e 100644
--- a/pkg/tests/apis/provisioning/provisioning_test.go
+++ b/pkg/tests/apis/provisioning/provisioning_test.go
@@ -136,7 +136,6 @@ func TestIntegrationProvisioning(t *testing.T) {
"testdata/local-devenv.json",
"testdata/local-tmp.json",
"testdata/local-xxx.json",
- "testdata/s3-example.json",
} {
t.Run(inputFilePath, func(t *testing.T) {
input := helper.LoadYAMLOrJSONFile(inputFilePath)
diff --git a/pkg/tests/apis/provisioning/testdata/s3-example.json b/pkg/tests/apis/provisioning/testdata/s3-example.json
deleted file mode 100644
index 7af6a5954b5..00000000000
--- a/pkg/tests/apis/provisioning/testdata/s3-example.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "apiVersion": "provisioning.grafana.app/v0alpha1",
- "kind": "Repository",
- "metadata": {
- "name": "s3-example"
- },
- "spec": {
- "description": "load resources from an S3 bucket",
- "s3": {
- "bucket": "my-bucket",
- "region": "us-west-1"
- },
- "sync": {
- "enabled": false,
- "target": "folder",
- "intervalSeconds": 60
- },
- "readOnly": false,
- "title": "S3 Example",
- "type": "s3"
- }
-}
\ No newline at end of file
diff --git a/pkg/util/strings.go b/pkg/util/strings.go
index f3a2d35540f..b3bbed21cf2 100644
--- a/pkg/util/strings.go
+++ b/pkg/util/strings.go
@@ -33,9 +33,18 @@ func stringsFallback(vals ...string) string {
// SplitString splits a string and returns a list of strings. It supports JSON list syntax and strings separated by commas or spaces.
// It supports quoted strings with spaces, e.g. "foo bar", "baz".
+// It will return an empty list if it fails to parse the string.
func SplitString(str string) []string {
+ result, _ := SplitStringWithError(str)
+ return result
+}
+
+// SplitStringWithError splits a string and returns a list of strings. It supports JSON list syntax and strings separated by commas or spaces.
+// It supports quoted strings with spaces, e.g. "foo bar", "baz".
+// It returns an error if it cannot parse the string.
+func SplitStringWithError(str string) ([]string, error) {
if len(str) == 0 {
- return []string{}
+ return []string{}, nil
}
// JSON list syntax support
@@ -43,9 +52,9 @@ func SplitString(str string) []string {
var res []string
err := json.Unmarshal([]byte(str), &res)
if err != nil {
- return []string{}
+ return []string{}, fmt.Errorf("incorrect format: %s", str)
}
- return res
+ return res, nil
}
matches := stringListItemMatcher.FindAllString(str, -1)
@@ -55,7 +64,7 @@ func SplitString(str string) []string {
result[i] = strings.Trim(match, "\"")
}
- return result
+ return result, nil
}
// GetAgeString returns a string representing certain time from years to minutes.
diff --git a/public/app/app.ts b/public/app/app.ts
index f4c4fd5d77c..697e909fc37 100644
--- a/public/app/app.ts
+++ b/public/app/app.ts
@@ -5,7 +5,6 @@ import 'whatwg-fetch'; // fetch polyfill needed for PhantomJs rendering
import 'file-saver';
import 'jquery';
-import _ from 'lodash'; // eslint-disable-line lodash/import-scope
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
@@ -40,12 +39,12 @@ import {
setChromeHeaderHeightHook,
setPluginLinksHook,
setCorrelationsService,
+ setPluginFunctionsHook,
} from '@grafana/runtime';
import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer';
import { setPluginPage } from '@grafana/runtime/src/components/PluginPage';
import config, { updateConfig } from 'app/core/config';
-import { arrayMove } from 'app/core/utils/arrayMove';
import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
import getDefaultMonacoLanguages from '../lib/monaco-languages';
@@ -66,13 +65,6 @@ import { backendSrv } from './core/services/backend_srv';
import { contextSrv, RedirectToUrlKey } from './core/services/context_srv';
import { Echo } from './core/services/echo/Echo';
import { reportPerformance } from './core/services/echo/EchoSrv';
-import { PerformanceBackend } from './core/services/echo/backends/PerformanceBackend';
-import { ApplicationInsightsBackend } from './core/services/echo/backends/analytics/ApplicationInsightsBackend';
-import { BrowserConsoleBackend } from './core/services/echo/backends/analytics/BrowseConsoleBackend';
-import { GA4EchoBackend } from './core/services/echo/backends/analytics/GA4Backend';
-import { GAEchoBackend } from './core/services/echo/backends/analytics/GABackend';
-import { RudderstackBackend } from './core/services/echo/backends/analytics/RudderstackBackend';
-import { GrafanaJavascriptAgentBackend } from './core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend';
import { KeybindingSrv } from './core/services/keybindingSrv';
import { startMeasure, stopMeasure } from './core/utils/metrics';
import { initDevFeatures } from './dev';
@@ -89,6 +81,7 @@ import { pluginExtensionRegistries } from './features/plugins/extensions/registr
import { usePluginComponent } from './features/plugins/extensions/usePluginComponent';
import { usePluginComponents } from './features/plugins/extensions/usePluginComponents';
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
+import { usePluginFunctions } from './features/plugins/extensions/usePluginFunctions';
import { usePluginLinks } from './features/plugins/extensions/usePluginLinks';
import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils';
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
@@ -111,10 +104,6 @@ import { createSystemVariableAdapter } from './features/variables/system/adapter
import { createTextBoxVariableAdapter } from './features/variables/textbox/adapter';
import { configureStore } from './store/configureStore';
-// add move to lodash for backward compatabilty with plugins
-// @ts-ignore
-_.move = arrayMove;
-
// import symlinked extensions
const extensionsIndex = require.context('.', true, /extensions\/index.ts/);
const extensionsExports = extensionsIndex.keys().map((key) => {
@@ -137,7 +126,7 @@ export class GrafanaApp {
initI18nPromise.then(({ language }) => updateConfig({ language }));
setBackendSrv(backendSrv);
- initEchoSrv();
+ await initEchoSrv();
// This needs to be done after the `initEchoSrv` since it is being used under the hood.
startMeasure('frontend_app_init');
@@ -229,6 +218,7 @@ export class GrafanaApp {
setPluginLinksHook(usePluginLinks);
setPluginComponentHook(usePluginComponent);
setPluginComponentsHook(usePluginComponents);
+ setPluginFunctionsHook(usePluginFunctions);
// initialize chrome service
const queryParams = locationService.getSearchObject();
@@ -292,7 +282,7 @@ function initExtensions() {
}
}
-function initEchoSrv() {
+async function initEchoSrv() {
setEchoSrv(new Echo({ debug: process.env.NODE_ENV === 'development' }));
window.addEventListener('load', (e) => {
@@ -312,6 +302,7 @@ function initEchoSrv() {
});
if (contextSrv.user.orgRole !== '') {
+ const { PerformanceBackend } = await import('./core/services/echo/backends/PerformanceBackend');
registerEchoBackend(new PerformanceBackend({}));
}
@@ -325,6 +316,10 @@ function initEchoSrv() {
.filter(Boolean)
.map((url) => new RegExp(`${url}.*.`));
+ const { GrafanaJavascriptAgentBackend } = await import(
+ './core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend'
+ );
+
registerEchoBackend(
new GrafanaJavascriptAgentBackend({
...config.grafanaJavascriptAgent,
@@ -343,6 +338,7 @@ function initEchoSrv() {
}
if (config.googleAnalyticsId) {
+ const { GAEchoBackend } = await import('./core/services/echo/backends/analytics/GABackend');
registerEchoBackend(
new GAEchoBackend({
googleAnalyticsId: config.googleAnalyticsId,
@@ -351,6 +347,7 @@ function initEchoSrv() {
}
if (config.googleAnalytics4Id) {
+ const { GA4EchoBackend } = await import('./core/services/echo/backends/analytics/GA4Backend');
registerEchoBackend(
new GA4EchoBackend({
googleAnalyticsId: config.googleAnalytics4Id,
@@ -360,6 +357,7 @@ function initEchoSrv() {
}
if (config.rudderstackWriteKey && config.rudderstackDataPlaneUrl) {
+ const { RudderstackBackend } = await import('./core/services/echo/backends/analytics/RudderstackBackend');
registerEchoBackend(
new RudderstackBackend({
writeKey: config.rudderstackWriteKey,
@@ -374,6 +372,9 @@ function initEchoSrv() {
}
if (config.applicationInsightsConnectionString) {
+ const { ApplicationInsightsBackend } = await import(
+ './core/services/echo/backends/analytics/ApplicationInsightsBackend'
+ );
registerEchoBackend(
new ApplicationInsightsBackend({
connectionString: config.applicationInsightsConnectionString,
@@ -383,6 +384,7 @@ function initEchoSrv() {
}
if (config.analyticsConsoleReporting) {
+ const { BrowserConsoleBackend } = await import('./core/services/echo/backends/analytics/BrowseConsoleBackend');
registerEchoBackend(new BrowserConsoleBackend());
}
}
@@ -392,7 +394,7 @@ function initEchoSrv() {
* like PerformanceMark or PerformancePaintTiming (e.g. created with performance.mark, or first-contentful-paint)
*/
function reportMetricPerformanceMark(metricName: string, prefix = '', suffix = ''): void {
- const metric = _.first(performance.getEntriesByName(metricName));
+ const metric = performance.getEntriesByName(metricName).at(0);
if (metric) {
const metricName = metric.name.replace(/-/g, '_');
reportPerformance(`${prefix}${metricName}${suffix}`, Math.round(metric.startTime) / 1000);
@@ -406,6 +408,7 @@ function handleRedirectTo(): void {
if (queryParams.has('auth_token')) {
// URL Login should not be redirected
window.sessionStorage.removeItem(RedirectToUrlKey);
+ return;
}
if (queryParams.has(redirectToParamKey) && window.location.pathname !== '/') {
diff --git a/public/app/core/components/OptionsUI/links.tsx b/public/app/core/components/OptionsUI/links.tsx
index 1a73b41c477..2d144b40e6c 100644
--- a/public/app/core/components/OptionsUI/links.tsx
+++ b/public/app/core/components/OptionsUI/links.tsx
@@ -3,13 +3,14 @@ import { DataLinksInlineEditor } from '@grafana/ui';
type Props = StandardEditorProps;
-export const DataLinksValueEditor = ({ value, onChange, context }: Props) => {
+export const DataLinksValueEditor = ({ value, onChange, context, item }: Props) => {
return (
(context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])}
+ showOneClick={item.settings?.showOneClick}
/>
);
};
diff --git a/public/app/core/components/OptionsUI/registry.tsx b/public/app/core/components/OptionsUI/registry.tsx
index cf4e0450c18..519a0f0c338 100644
--- a/public/app/core/components/OptionsUI/registry.tsx
+++ b/public/app/core/components/OptionsUI/registry.tsx
@@ -27,6 +27,7 @@ import {
FieldNamePickerConfigSettings,
booleanOverrideProcessor,
Action,
+ DataLinksFieldConfigSettings,
} from '@grafana/data';
import { actionsOverrideProcessor } from '@grafana/data/src/field/overrides/processors';
import { config } from '@grafana/runtime';
@@ -350,7 +351,7 @@ export const getAllStandardFieldConfigs = () => {
const dataLinksCategory = config.featureToggles.vizActions ? 'Data links and actions' : 'Data links';
- const links: FieldConfigPropertyItem = {
+ const links: FieldConfigPropertyItem = {
id: 'links',
path: 'links',
name: 'Data links',
@@ -358,14 +359,14 @@ export const getAllStandardFieldConfigs = () => {
override: standardEditorsRegistry.get('links').editor,
process: dataLinksOverrideProcessor,
settings: {
- placeholder: '-',
+ showOneClick: false,
},
shouldApply: () => true,
category: [dataLinksCategory],
getItemsCount: (value) => (value ? value.length : 0),
};
- const actions: FieldConfigPropertyItem = {
+ const actions: FieldConfigPropertyItem = {
id: 'actions',
path: 'actions',
name: 'Actions',
@@ -373,12 +374,13 @@ export const getAllStandardFieldConfigs = () => {
override: standardEditorsRegistry.get('actions').editor,
process: actionsOverrideProcessor,
settings: {
- placeholder: '-',
+ showOneClick: false,
},
shouldApply: () => true,
category: [dataLinksCategory],
getItemsCount: (value) => (value ? value.length : 0),
showIf: () => config.featureToggles.vizActions,
+ hideFromDefaults: true,
};
const color: FieldConfigPropertyItem = {
diff --git a/public/app/core/components/Select/ServiceAccountPicker.tsx b/public/app/core/components/Select/ServiceAccountPicker.tsx
index 28c8e3441d2..b1c0e476891 100644
--- a/public/app/core/components/Select/ServiceAccountPicker.tsx
+++ b/public/app/core/components/Select/ServiceAccountPicker.tsx
@@ -38,7 +38,7 @@ export class ServiceAccountPicker extends Component {
}
return getBackendSrv()
- .get(`/api/serviceaccounts/search`)
+ .get(`/api/serviceaccounts/search?query=${query}&perpage=100`)
.then((result: ServiceAccountsState) => {
return result.serviceAccounts.map((sa) => ({
id: sa.id,
diff --git a/public/app/core/components/TagFilter/TagOption.tsx b/public/app/core/components/TagFilter/TagOption.tsx
index 986d3f9ec7d..57abca0f942 100644
--- a/public/app/core/components/TagFilter/TagOption.tsx
+++ b/public/app/core/components/TagFilter/TagOption.tsx
@@ -27,16 +27,17 @@ export const TagOption = ({ data, className, label, isFocused, innerProps }: Opt
const getStyles = (theme: GrafanaTheme2) => {
return {
option: css({
- padding: theme.spacing(1),
+ padding: theme.spacing(0.5),
whiteSpace: 'nowrap',
cursor: 'pointer',
borderLeft: '2px solid transparent',
+ borderRadius: theme.shape.radius.default,
'&:hover': {
- background: theme.colors.background.secondary,
+ background: theme.colors.action.hover,
},
}),
optionFocused: css({
- background: theme.colors.background.secondary,
+ background: theme.colors.action.focus,
borderStyle: 'solid',
borderTop: 0,
borderRight: 0,
diff --git a/public/app/core/utils/object.test.ts b/public/app/core/utils/object.test.ts
index 8e27feccc6b..c84f7d71d9e 100644
--- a/public/app/core/utils/object.test.ts
+++ b/public/app/core/utils/object.test.ts
@@ -7,6 +7,7 @@ describe('objects', () => {
deeper: 10,
foo: null,
arr: [null, 1, 'hello'],
+ value: -Infinity,
},
bar: undefined,
simple: 'A',
diff --git a/public/app/core/utils/object.ts b/public/app/core/utils/object.ts
index a5cccf419c2..a51d593b635 100644
--- a/public/app/core/utils/object.ts
+++ b/public/app/core/utils/object.ts
@@ -1,16 +1,17 @@
import { isArray, isPlainObject } from 'lodash';
/** @returns a deep clone of the object, but with any null value removed */
-export function sortedDeepCloneWithoutNulls(value: T): T {
+export function sortedDeepCloneWithoutNulls(value: T): T {
if (isArray(value)) {
return value.map(sortedDeepCloneWithoutNulls) as unknown as T;
}
if (isPlainObject(value)) {
- return Object.keys(value)
+ return Object.keys(value as { [key: string]: any })
.sort()
.reduce((acc: any, key) => {
const v = (value as any)[key];
- if (v != null) {
+ // Remove null values and also -Infinity which is not a valid JSON value
+ if (v != null && v !== -Infinity) {
acc[key] = sortedDeepCloneWithoutNulls(v);
}
return acc;
diff --git a/public/app/features/actions/ActionsInlineEditor.tsx b/public/app/features/actions/ActionsInlineEditor.tsx
index 7cdb1ff34a4..9cc9550db1f 100644
--- a/public/app/features/actions/ActionsInlineEditor.tsx
+++ b/public/app/features/actions/ActionsInlineEditor.tsx
@@ -9,7 +9,12 @@ type DataLinksInlineEditorProps = Omit, '
getSuggestions: () => VariableSuggestion[];
};
-export const ActionsInlineEditor = ({ actions, getSuggestions, showOneClick, ...rest }: DataLinksInlineEditorProps) => (
+export const ActionsInlineEditor = ({
+ actions,
+ getSuggestions,
+ showOneClick = false,
+ ...rest
+}: DataLinksInlineEditorProps) => (
type="action" items={actions} {...rest}>
{(item, index, onSave, onCancel) => (
)}
diff --git a/public/app/features/actions/utils.ts b/public/app/features/actions/utils.ts
index 29751c4f4e3..3874b166444 100644
--- a/public/app/features/actions/utils.ts
+++ b/public/app/features/actions/utils.ts
@@ -62,6 +62,7 @@ export const getActions = (
onClick: (evt: MouseEvent, origin: Field) => {
buildActionOnClick(action, boundReplaceVariables);
},
+ oneClick: action.oneClick ?? false,
};
return actionModel;
diff --git a/public/app/features/alerting/unified/AlertGroups.tsx b/public/app/features/alerting/unified/AlertGroups.tsx
index 3422a093990..2d5863f7aea 100644
--- a/public/app/features/alerting/unified/AlertGroups.tsx
+++ b/public/app/features/alerting/unified/AlertGroups.tsx
@@ -19,6 +19,7 @@ import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { getFiltersFromUrlParams } from './utils/misc';
import { initialAsyncRequestState } from './utils/redux';
+import { withPageErrorBoundary } from './withPageErrorBoundary';
const AlertGroups = () => {
const { selectedAlertmanager } = useAlertmanager();
@@ -89,10 +90,12 @@ const AlertGroups = () => {
);
};
-const AlertGroupsPage = () => (
-
-
-
-);
+function AlertGroupsPage() {
+ return (
+
+
+
+ );
+}
-export default AlertGroupsPage;
+export default withPageErrorBoundary(AlertGroupsPage);
diff --git a/public/app/features/alerting/unified/AlertingNotEnabled.tsx b/public/app/features/alerting/unified/AlertingNotEnabled.tsx
index ca8a10aa487..9250fafed2c 100644
--- a/public/app/features/alerting/unified/AlertingNotEnabled.tsx
+++ b/public/app/features/alerting/unified/AlertingNotEnabled.tsx
@@ -1,7 +1,9 @@
import { NavModel } from '@grafana/data';
import { Page } from 'app/core/components/Page/Page';
-export default function FeatureTogglePage() {
+import { withPageErrorBoundary } from './withPageErrorBoundary';
+
+function FeatureTogglePage() {
const navModel: NavModel = {
node: {
text: 'Alerting is not enabled',
@@ -25,3 +27,5 @@ enabled = true
);
}
+
+export default withPageErrorBoundary(FeatureTogglePage);
diff --git a/public/app/features/alerting/unified/NewSilencePage.tsx b/public/app/features/alerting/unified/NewSilencePage.tsx
index 859b9c3875e..2f2dc4e79cd 100644
--- a/public/app/features/alerting/unified/NewSilencePage.tsx
+++ b/public/app/features/alerting/unified/NewSilencePage.tsx
@@ -1,6 +1,5 @@
import { useLocation } from 'react-router-dom-v5-compat';
-import { withErrorBoundary } from '@grafana/ui';
import {
defaultsFromQuery,
getDefaultSilenceFormValues,
@@ -12,6 +11,7 @@ import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning';
import { SilencesEditor } from './components/silences/SilencesEditor';
import { useAlertmanager } from './state/AlertmanagerContext';
+import { withPageErrorBoundary } from './withPageErrorBoundary';
const SilencesEditorComponent = () => {
const location = useLocation();
@@ -48,4 +48,5 @@ function NewSilencePage() {
);
}
-export default withErrorBoundary(NewSilencePage, { style: 'page' });
+
+export default withPageErrorBoundary(NewSilencePage);
diff --git a/public/app/features/alerting/unified/NotificationPoliciesPage.tsx b/public/app/features/alerting/unified/NotificationPoliciesPage.tsx
index 6b4d1b9b5e7..25d2ef8cbf8 100644
--- a/public/app/features/alerting/unified/NotificationPoliciesPage.tsx
+++ b/public/app/features/alerting/unified/NotificationPoliciesPage.tsx
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { useState } from 'react';
import { GrafanaTheme2, UrlQueryMap } from '@grafana/data';
-import { Tab, TabContent, TabsBar, useStyles2, withErrorBoundary } from '@grafana/ui';
+import { Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { useMuteTimings } from 'app/features/alerting/unified/components/mute-timings/useMuteTimings';
import { NotificationPoliciesList } from 'app/features/alerting/unified/components/notification-policies/NotificationPoliciesList';
@@ -12,6 +12,7 @@ import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning';
import { MuteTimingsTable } from './components/mute-timings/MuteTimingsTable';
import { useAlertmanager } from './state/AlertmanagerContext';
+import { withPageErrorBoundary } from './withPageErrorBoundary';
enum ActiveTab {
NotificationPolicies = 'notification_policies',
@@ -104,10 +105,12 @@ function getActiveTabFromUrl(queryParams: UrlQueryMap, defaultTab: ActiveTab): Q
};
}
-const NotificationPoliciesPage = () => (
-
-
-
-);
+function NotificationPoliciesPage() {
+ return (
+
+
+
+ );
+}
-export default withErrorBoundary(NotificationPoliciesPage, { style: 'page' });
+export default withPageErrorBoundary(NotificationPoliciesPage);
diff --git a/public/app/features/alerting/unified/RedirectToRuleViewer.tsx b/public/app/features/alerting/unified/RedirectToRuleViewer.tsx
index e5375ad9133..bb032b5a9fd 100644
--- a/public/app/features/alerting/unified/RedirectToRuleViewer.tsx
+++ b/public/app/features/alerting/unified/RedirectToRuleViewer.tsx
@@ -5,7 +5,7 @@ import { useLocation } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { config, isFetchError } from '@grafana/runtime';
-import { Alert, Card, Icon, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui';
+import { Alert, Card, Icon, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import { AlertLabels } from './components/AlertLabels';
import { RuleViewerLayout } from './components/rule-viewer/RuleViewerLayout';
@@ -13,6 +13,7 @@ import { useCloudCombinedRulesMatching } from './hooks/useCombinedRule';
import { getRulesSourceByName } from './utils/datasource';
import { createViewLink } from './utils/misc';
import { unescapePathSeparators } from './utils/rule-id';
+import { withPageErrorBoundary } from './withPageErrorBoundary';
const pageTitle = 'Find rule';
const subUrl = config.appSubUrl;
@@ -153,4 +154,4 @@ function getStyles(theme: GrafanaTheme2) {
};
}
-export default withErrorBoundary(RedirectToRuleViewer, { style: 'page' });
+export default withPageErrorBoundary(RedirectToRuleViewer);
diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx
index 9c38d4bf049..f51f440bd14 100644
--- a/public/app/features/alerting/unified/RuleList.tsx
+++ b/public/app/features/alerting/unified/RuleList.tsx
@@ -3,6 +3,7 @@ import { Suspense, lazy } from 'react';
import { config } from '@grafana/runtime';
import RuleListV1 from './rule-list/RuleList.v1';
+import { withPageErrorBoundary } from './withPageErrorBoundary';
const RuleListV2 = lazy(() => import('./rule-list/RuleList.v2'));
const RuleList = () => {
@@ -11,4 +12,4 @@ const RuleList = () => {
return {newView ? : } ;
};
-export default RuleList;
+export default withPageErrorBoundary(RuleList);
diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx
index 9584539e41b..9df84a41b18 100644
--- a/public/app/features/alerting/unified/RuleViewer.tsx
+++ b/public/app/features/alerting/unified/RuleViewer.tsx
@@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { isFetchError } from '@grafana/runtime';
-import { Alert, withErrorBoundary } from '@grafana/ui';
+import { Alert } from '@grafana/ui';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
@@ -12,6 +12,7 @@ import DetailView, { ActiveTab, useActiveTab } from './components/rule-viewer/Ru
import { useCombinedRule } from './hooks/useCombinedRule';
import { stringifyErrorLike } from './utils/misc';
import { getRuleIdFromPathname, parse as parseRuleId } from './utils/rule-id';
+import { withPageErrorBoundary } from './withPageErrorBoundary';
const RuleViewer = (): JSX.Element => {
const params = useParams();
@@ -86,4 +87,4 @@ function ErrorMessage({ error }: ErrorMessageProps) {
return {stringifyErrorLike(error)} ;
}
-export default withErrorBoundary(RuleViewer, { style: 'page' });
+export default withPageErrorBoundary(RuleViewer);
diff --git a/public/app/features/alerting/unified/Settings.tsx b/public/app/features/alerting/unified/Settings.tsx
index 099bde572b0..7d3a91dafdf 100644
--- a/public/app/features/alerting/unified/Settings.tsx
+++ b/public/app/features/alerting/unified/Settings.tsx
@@ -6,8 +6,9 @@ import { useEditConfigurationDrawer } from './components/settings/ConfigurationD
import { ExternalAlertmanagers } from './components/settings/ExternalAlertmanagers';
import InternalAlertmanager from './components/settings/InternalAlertmanager';
import { SettingsProvider, useSettings } from './components/settings/SettingsContext';
+import { withPageErrorBoundary } from './withPageErrorBoundary';
-export default function SettingsPage() {
+function SettingsPage() {
return (
@@ -47,3 +48,5 @@ function SettingsContent() {
);
}
+
+export default withPageErrorBoundary(SettingsPage);
diff --git a/public/app/features/alerting/unified/Templates.tsx b/public/app/features/alerting/unified/Templates.tsx
index d32ea0541b4..f3b8b011f36 100644
--- a/public/app/features/alerting/unified/Templates.tsx
+++ b/public/app/features/alerting/unified/Templates.tsx
@@ -1,28 +1,29 @@
import { Route, Routes } from 'react-router-dom-v5-compat';
-import { withErrorBoundary } from '@grafana/ui';
-
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import DuplicateMessageTemplate from './components/contact-points/DuplicateMessageTemplate';
import EditMessageTemplate from './components/contact-points/EditMessageTemplate';
import NewMessageTemplate from './components/contact-points/NewMessageTemplate';
+import { withPageErrorBoundary } from './withPageErrorBoundary';
-const NotificationTemplates = (): JSX.Element => (
-
-
- } />
- } />
- } />
-
-
-);
+function NotificationTemplates() {
+ return (
+
+
+ } />
+ } />
+ } />
+
+
+ );
+}
-export default withErrorBoundary(NotificationTemplates, { style: 'page' });
+export default withPageErrorBoundary(NotificationTemplates);
diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx
index 5ea42c97584..0d2f6cf447e 100644
--- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx
@@ -12,7 +12,6 @@ import {
TabContent,
TabsBar,
Text,
- withErrorBoundary,
} from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { Trans, t } from 'app/core/internationalization';
@@ -25,6 +24,7 @@ import { usePagination } from '../../hooks/usePagination';
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
+import { withPageErrorBoundary } from '../../withPageErrorBoundary';
import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning';
@@ -270,4 +270,4 @@ function ContactPointsPage() {
);
}
-export default withErrorBoundary(ContactPointsPage, { style: 'page' });
+export default withPageErrorBoundary(ContactPointsPage);
diff --git a/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx b/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx
index 3ded1052b5d..bc89effea98 100644
--- a/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx
@@ -8,13 +8,15 @@ import { useAlertmanager } from '../../state/AlertmanagerContext';
import { generateCopiedName } from '../../utils/duplicate';
import { stringifyErrorLike } from '../../utils/misc';
import { updateDefinesWithUniqueValue } from '../../utils/templates';
+import { withPageErrorBoundary } from '../../withPageErrorBoundary';
+import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
import { TemplateForm } from '../receivers/TemplateForm';
import { useGetNotificationTemplate, useNotificationTemplates } from './useNotificationTemplates';
const notFoundComponent = ;
-const DuplicateMessageTemplate = () => {
+const DuplicateMessageTemplateComponent = () => {
const { selectedAlertmanager } = useAlertmanager();
const { name } = useParams<{ name: string }>();
const templateUid = name ? decodeURIComponent(name) : undefined;
@@ -63,4 +65,12 @@ const DuplicateMessageTemplate = () => {
);
};
-export default DuplicateMessageTemplate;
+function DuplicateMessageTemplate() {
+ return (
+
+
+
+ );
+}
+
+export default withPageErrorBoundary(DuplicateMessageTemplate);
diff --git a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx
index 96a00fc1d33..5e9deb4b3da 100644
--- a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx
@@ -1,10 +1,11 @@
import { useParams } from 'react-router-dom-v5-compat';
-import { Alert, LoadingPlaceholder, withErrorBoundary } from '@grafana/ui';
+import { Alert, LoadingPlaceholder } from '@grafana/ui';
import { useGetContactPoint } from 'app/features/alerting/unified/components/contact-points/useContactPoints';
import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
import { useAlertmanager } from '../../state/AlertmanagerContext';
+import { withPageErrorBoundary } from '../../withPageErrorBoundary';
import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
import { EditReceiverView } from '../receivers/EditReceiverView';
@@ -50,4 +51,4 @@ function EditContactPointPage() {
);
}
-export default withErrorBoundary(EditContactPointPage, { style: 'page' });
+export default withPageErrorBoundary(EditContactPointPage);
diff --git a/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx b/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx
index 6c7df2a7fad..926e1c5404b 100644
--- a/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx
@@ -6,13 +6,15 @@ import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'
import { isNotFoundError } from '../../api/util';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { stringifyErrorLike } from '../../utils/misc';
+import { withPageErrorBoundary } from '../../withPageErrorBoundary';
+import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
import { TemplateForm } from '../receivers/TemplateForm';
import { useGetNotificationTemplate } from './useNotificationTemplates';
const notFoundComponent = ;
-const EditMessageTemplate = () => {
+const EditMessageTemplateComponent = () => {
const { name } = useParams<{ name: string }>();
const templateUid = name ? decodeURIComponent(name) : undefined;
@@ -47,4 +49,12 @@ const EditMessageTemplate = () => {
return ;
};
-export default EditMessageTemplate;
+function EditMessageTemplate() {
+ return (
+
+
+
+ );
+}
+
+export default withPageErrorBoundary(EditMessageTemplate);
diff --git a/public/app/features/alerting/unified/components/contact-points/NewMessageTemplate.tsx b/public/app/features/alerting/unified/components/contact-points/NewMessageTemplate.tsx
index e11ac531390..7cdd2286be1 100644
--- a/public/app/features/alerting/unified/components/contact-points/NewMessageTemplate.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/NewMessageTemplate.tsx
@@ -1,16 +1,16 @@
-import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
-
import { useAlertmanager } from '../../state/AlertmanagerContext';
+import { withPageErrorBoundary } from '../../withPageErrorBoundary';
+import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
import { TemplateForm } from '../receivers/TemplateForm';
-const NewMessageTemplate = () => {
+function NewMessageTemplate() {
const { selectedAlertmanager } = useAlertmanager();
- if (!selectedAlertmanager) {
- return ;
- }
+ return (
+
+
+
+ );
+}
- return ;
-};
-
-export default NewMessageTemplate;
+export default withPageErrorBoundary(NewMessageTemplate);
diff --git a/public/app/features/alerting/unified/components/contact-points/components/GlobalConfig.tsx b/public/app/features/alerting/unified/components/contact-points/components/GlobalConfig.tsx
index e5514b01d66..42ec581c529 100644
--- a/public/app/features/alerting/unified/components/contact-points/components/GlobalConfig.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/components/GlobalConfig.tsx
@@ -1,11 +1,12 @@
-import { Alert, withErrorBoundary } from '@grafana/ui';
+import { Alert } from '@grafana/ui';
import { useAlertmanagerConfig } from '../../../hooks/useAlertmanagerConfig';
import { useAlertmanager } from '../../../state/AlertmanagerContext';
+import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
import { AlertmanagerPageWrapper } from '../../AlertingPageWrapper';
import { GlobalConfigForm } from '../../receivers/GlobalConfigForm';
-const NewMessageTemplate = () => {
+const GlobalConfig = () => {
const { selectedAlertmanager } = useAlertmanager();
const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager);
@@ -28,12 +29,12 @@ const NewMessageTemplate = () => {
return ;
};
-function NewMessageTemplatePage() {
+function GlobalConfigPage() {
return (
-
+
);
}
-export default withErrorBoundary(NewMessageTemplatePage, { style: 'page' });
+export default withPageErrorBoundary(GlobalConfigPage);
diff --git a/public/app/features/alerting/unified/components/export/ExportNewGrafanaRule.tsx b/public/app/features/alerting/unified/components/export/ExportNewGrafanaRule.tsx
index 2e99bc0dc41..614f74eaa2a 100644
--- a/public/app/features/alerting/unified/components/export/ExportNewGrafanaRule.tsx
+++ b/public/app/features/alerting/unified/components/export/ExportNewGrafanaRule.tsx
@@ -1,21 +1,8 @@
-import * as React from 'react';
-
+import { withPageErrorBoundary } from '../../withPageErrorBoundary';
import { AlertingPageWrapper } from '../AlertingPageWrapper';
import { ModifyExportRuleForm } from '../rule-editor/alert-rule-form/ModifyExportRuleForm';
-export default function ExportNewGrafanaRule() {
- return (
-
-
-
- );
-}
-
-interface ExportNewGrafanaRuleWrapperProps {
- children: React.ReactNode;
-}
-
-function ExportNewGrafanaRuleWrapper({ children }: ExportNewGrafanaRuleWrapperProps) {
+function ExportNewGrafanaRulePage() {
return (
- {children}
+
);
}
+
+export default withPageErrorBoundary(ExportNewGrafanaRulePage);
diff --git a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx
index 5aca1d77824..1c4241fe519 100644
--- a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx
+++ b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx
@@ -1,4 +1,3 @@
-import * as React from 'react';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
@@ -12,10 +11,11 @@ import { stringifyErrorLike } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { isGrafanaRulerRule } from '../../utils/rules';
import { createRelativeUrl } from '../../utils/url';
+import { withPageErrorBoundary } from '../../withPageErrorBoundary';
import { AlertingPageWrapper } from '../AlertingPageWrapper';
import { ModifyExportRuleForm } from '../rule-editor/alert-rule-form/ModifyExportRuleForm';
-export default function GrafanaModifyExport() {
+function GrafanaModifyExport() {
const { id } = useParams();
const ruleIdentifier = useMemo(() => {
return ruleId.tryParse(id, true);
@@ -23,38 +23,13 @@ export default function GrafanaModifyExport() {
if (!ruleIdentifier) {
return (
-
-
- The rule UID in the page URL is invalid. Please check the URL and try again.
-
-
+
+ The rule UID in the page URL is invalid. Please check the URL and try again.
+
);
}
- return (
-
-
-
- );
-}
-
-interface ModifyExportWrapperProps {
- children: React.ReactNode;
-}
-
-function ModifyExportWrapper({ children }: ModifyExportWrapperProps) {
- return (
-
- {children}
-
- );
+ return ;
}
function RuleModifyExport({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }) {
@@ -105,3 +80,20 @@ function RuleModifyExport({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }
return ;
}
+
+function GrafanaModifyExportPage() {
+ return (
+
+
+
+ );
+}
+
+export default withPageErrorBoundary(GrafanaModifyExportPage);
diff --git a/public/app/features/alerting/unified/components/mute-timings/EditMuteTiming.tsx b/public/app/features/alerting/unified/components/mute-timings/EditMuteTiming.tsx
index f73b1a1d1af..f697b935408 100644
--- a/public/app/features/alerting/unified/components/mute-timings/EditMuteTiming.tsx
+++ b/public/app/features/alerting/unified/components/mute-timings/EditMuteTiming.tsx
@@ -1,10 +1,10 @@
import { Navigate } from 'react-router-dom-v5-compat';
-import { withErrorBoundary } from '@grafana/ui';
import { useGetMuteTiming } from 'app/features/alerting/unified/components/mute-timings/useMuteTimings';
import { useURLSearchParams } from 'app/features/alerting/unified/hooks/useURLSearchParams';
import { useAlertmanager } from '../../state/AlertmanagerContext';
+import { withPageErrorBoundary } from '../../withPageErrorBoundary';
import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
import MuteTimingForm from './MuteTimingForm';
@@ -38,17 +38,16 @@ const EditTimingRoute = () => {
);
};
-const EditMuteTimingPage = () => (
-
-
-
-);
+function EditMuteTimingPage() {
+ return (
+
+
+
+ );
+}
-export default withErrorBoundary(EditMuteTimingPage, { style: 'page' });
+export default withPageErrorBoundary(EditMuteTimingPage);
diff --git a/public/app/features/alerting/unified/components/mute-timings/NewMuteTiming.tsx b/public/app/features/alerting/unified/components/mute-timings/NewMuteTiming.tsx
index b73dda0e94f..c60e6d0f990 100644
--- a/public/app/features/alerting/unified/components/mute-timings/NewMuteTiming.tsx
+++ b/public/app/features/alerting/unified/components/mute-timings/NewMuteTiming.tsx
@@ -1,20 +1,18 @@
-import { withErrorBoundary } from '@grafana/ui';
-
+import { withPageErrorBoundary } from '../../withPageErrorBoundary';
import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
import MuteTimingForm from './MuteTimingForm';
-const NewMuteTimingPage = () => (
-
-
-
-);
+function NewMuteTimingPage() {
+ return (
+
+
+
+ );
+}
-export default withErrorBoundary(NewMuteTimingPage, { style: 'page' });
+export default withPageErrorBoundary(NewMuteTimingPage);
diff --git a/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx b/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx
index 4f2a3322da4..9258dddfc89 100644
--- a/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx
+++ b/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx
@@ -1,7 +1,7 @@
-import { withErrorBoundary } from '@grafana/ui';
import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
+import { withPageErrorBoundary } from '../../withPageErrorBoundary';
import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
import { CloudReceiverForm } from './form/CloudReceiverForm';
@@ -24,4 +24,4 @@ function NewReceiverViewPage() {
);
}
-export default withErrorBoundary(NewReceiverViewPage, { style: 'page' });
+export default withPageErrorBoundary(NewReceiverViewPage);
diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage.tsx
index eb767a1df2f..91463309afd 100644
--- a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage.tsx
+++ b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage.tsx
@@ -1,14 +1,14 @@
-import { withErrorBoundary } from '@grafana/ui';
-
+import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
import { CentralAlertHistoryScene } from './CentralAlertHistoryScene';
-const HistoryPage = () => {
+function HistoryPage() {
return (
);
-};
-export default withErrorBoundary(HistoryPage, { style: 'page' });
+}
+
+export default withPageErrorBoundary(HistoryPage);
diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx
index c72cb1e5521..2a1c1be335c 100644
--- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx
+++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx
@@ -25,7 +25,6 @@ import {
Stack,
TextArea,
useStyles2,
- withErrorBoundary,
} from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { SilenceCreatedResponse, alertSilencesApi } from 'app/features/alerting/unified/api/alertSilencesApi';
@@ -38,6 +37,7 @@ import { useAlertmanager } from '../../state/AlertmanagerContext';
import { SilenceFormFields } from '../../types/silence-form';
import { matcherFieldToMatcher } from '../../utils/alertmanager';
import { makeAMLink } from '../../utils/misc';
+import { withPageErrorBoundary } from '../../withPageErrorBoundary';
import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning';
@@ -296,4 +296,5 @@ function ExistingSilenceEditorPage() {
);
}
-export default withErrorBoundary(ExistingSilenceEditorPage, { style: 'page' });
+
+export default withPageErrorBoundary(ExistingSilenceEditorPage);
diff --git a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx
index 6eaf819d11a..2faa5a2b1c8 100644
--- a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx
+++ b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx
@@ -12,7 +12,6 @@ import {
LoadingPlaceholder,
Stack,
useStyles2,
- withErrorBoundary,
} from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { Trans } from 'app/core/internationalization';
@@ -27,6 +26,7 @@ import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbili
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { parsePromQLStyleMatcherLooseSafe } from '../../utils/matchers';
import { getSilenceFiltersFromUrlParams, makeAMLink, stringifyErrorLike } from '../../utils/misc';
+import { withPageErrorBoundary } from '../../withPageErrorBoundary';
import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
import { Authorize } from '../Authorize';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
@@ -393,4 +393,5 @@ function SilencesTablePage() {
);
}
-export default withErrorBoundary(SilencesTablePage, { style: 'page' });
+
+export default withPageErrorBoundary(SilencesTablePage);
diff --git a/public/app/features/alerting/unified/home/Home.tsx b/public/app/features/alerting/unified/home/Home.tsx
index 898bfdebefb..023b6a237a3 100644
--- a/public/app/features/alerting/unified/home/Home.tsx
+++ b/public/app/features/alerting/unified/home/Home.tsx
@@ -5,12 +5,13 @@ import { Box, Stack, Tab, TabContent, TabsBar } from '@grafana/ui';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { isLocalDevEnv } from '../utils/misc';
+import { withPageErrorBoundary } from '../withPageErrorBoundary';
import GettingStarted, { WelcomeHeader } from './GettingStarted';
import { getInsightsScenes, insightsIsAvailable } from './Insights';
import { PluginIntegrations } from './PluginIntegrations';
-export default function Home() {
+function Home() {
const insightsEnabled = (insightsIsAvailable() || isLocalDevEnv()) && Boolean(config.featureToggles.alertingInsights);
const [activeTab, setActiveTab] = useState<'insights' | 'overview'>(insightsEnabled ? 'insights' : 'overview');
@@ -51,3 +52,5 @@ export default function Home() {
);
}
+
+export default withPageErrorBoundary(Home);
diff --git a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts
index 1d30b05ad50..e0a0142993f 100644
--- a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts
+++ b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts
@@ -24,6 +24,7 @@ export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => {
addedComponents: [],
extensionPoints: [],
exposedComponents: [],
+ addedFunctions: [],
},
dependencies: {
grafanaVersion: '',
diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx
index b5adba57cf2..42585600052 100644
--- a/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx
+++ b/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx
@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
-import { withErrorBoundary } from '@grafana/ui';
import { RuleIdentifier } from 'app/types/unified-alerting';
import { AlertWarning } from '../AlertWarning';
@@ -11,6 +10,7 @@ import { AlertRuleForm } from '../components/rule-editor/alert-rule-form/AlertRu
import { useURLSearchParams } from '../hooks/useURLSearchParams';
import { useRulesAccess } from '../utils/accessControlHooks';
import * as ruleId from '../utils/rule-id';
+import { withPageErrorBoundary } from '../withPageErrorBoundary';
import { CloneRuleEditor } from './CloneRuleEditor';
import { ExistingRuleEditor } from './ExistingRuleEditor';
@@ -78,7 +78,9 @@ const RuleEditor = () => {
);
};
-export default withErrorBoundary(RuleEditor, { style: 'page' });
+// The pageNav property makes it difficult to only rely on AlertingPageWrapper
+// to catch errors.
+export default withPageErrorBoundary(RuleEditor);
function useRuleEditorPathParams() {
const params = useParams();
diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx
index d90c4b238c0..759e7b48474 100644
--- a/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx
+++ b/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx
@@ -4,7 +4,7 @@ import { useAsyncFn, useInterval } from 'react-use';
import { urlUtil } from '@grafana/data';
import { logInfo } from '@grafana/runtime';
-import { Button, LinkButton, Stack, withErrorBoundary } from '@grafana/ui';
+import { Button, LinkButton, Stack } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { Trans } from 'app/core/internationalization';
import { useDispatch } from 'app/types';
@@ -155,7 +155,7 @@ const RuleListV1 = () => {
);
};
-export default withErrorBoundary(RuleListV1, { style: 'page' });
+export default RuleListV1;
export function CreateAlertButton() {
const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule);
diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx
index b8ccbb2fa8f..86949386e3a 100644
--- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx
+++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx
@@ -1,5 +1,3 @@
-import { withErrorBoundary } from '@grafana/ui';
-
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import RulesFilter from '../components/rules/Filter/RulesFilter';
import { SupportedView } from '../components/rules/Filter/RulesViewModeSelector';
@@ -9,24 +7,25 @@ import { useURLSearchParams } from '../hooks/useURLSearchParams';
import { FilterView } from './FilterView';
import { GroupedView } from './GroupedView';
-const RuleList = withErrorBoundary(
- () => {
- const [queryParams] = useURLSearchParams();
- const { filterState, hasActiveFilters } = useRulesFilter();
+function RuleList() {
+ const [queryParams] = useURLSearchParams();
+ const { filterState, hasActiveFilters } = useRulesFilter();
- const view: SupportedView = queryParams.get('view') === 'list' ? 'list' : 'grouped';
- const showListView = hasActiveFilters || view === 'list';
+ const view: SupportedView = queryParams.get('view') === 'list' ? 'list' : 'grouped';
+ const showListView = hasActiveFilters || view === 'list';
- return (
- // We don't want to show the Loading... indicator for the whole page.
- // We show separate indicators for Grafana-managed and Cloud rules
-
- {}} />
- {showListView ? : }
-
- );
- },
- { style: 'page' }
-);
+ return (
+ <>
+ {}} />
+ {showListView ? : }
+ >
+ );
+}
-export default RuleList;
+export default function RuleListPage() {
+ return (
+
+
+
+ );
+}
diff --git a/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx b/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx
index 9df0aa641b3..11f4f5cbf01 100644
--- a/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx
+++ b/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx
@@ -1,14 +1,16 @@
import { css } from '@emotion/css';
import { PropsWithChildren, ReactNode } from 'react';
+import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
-import { LinkButton, Stack, Text, useStyles2 } from '@grafana/ui';
+import { IconButton, LinkButton, Stack, Text, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
-import { RulesSourceIdentifier } from 'app/types/unified-alerting';
+import { GrafanaRulesSourceSymbol, RulesSourceIdentifier } from 'app/types/unified-alerting';
import { RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { Spacer } from '../../components/Spacer';
import { WithReturnButton } from '../../components/WithReturnButton';
+import { isAdmin } from '../../utils/misc';
import { DataSourceIcon } from './Namespace';
import { LoadingIndicator } from './RuleGroup';
@@ -32,7 +34,17 @@ export const DataSourceSection = ({
description = null,
}: DataSourceSectionProps) => {
const styles = useStyles2(getStyles);
-
+ const [isCollapsed, toggleCollapsed] = useToggle(false);
+ const configureLink = (() => {
+ if (uid === GrafanaRulesSourceSymbol) {
+ const userIsAdmin = isAdmin();
+ if (!userIsAdmin) {
+ return;
+ }
+ return '/alerting/admin';
+ }
+ return `/connections/datasources/edit/${String(uid)}`;
+ })();
return (
@@ -41,7 +53,13 @@ export const DataSourceSection = ({
{loader ?? (
+
{application && }
+
{name}
@@ -52,19 +70,21 @@ export const DataSourceSection = ({
>
)}
-
- Configure
-
- }
- />
+ {configureLink && (
+
+ Configure
+
+ }
+ />
+ )}
)}
- {children}
+ {!isCollapsed && {children}
}
);
diff --git a/public/app/features/alerting/unified/testSetup/plugins.ts b/public/app/features/alerting/unified/testSetup/plugins.ts
index dd3fb82ee76..72a94187ef0 100644
--- a/public/app/features/alerting/unified/testSetup/plugins.ts
+++ b/public/app/features/alerting/unified/testSetup/plugins.ts
@@ -163,6 +163,7 @@ export function pluginMetaToPluginConfig(pluginMeta: PluginMeta): AppPluginConfi
addedComponents: [],
extensionPoints: [],
exposedComponents: [],
+ addedFunctions: [],
},
};
}
diff --git a/public/app/features/alerting/unified/utils/rules.test.ts b/public/app/features/alerting/unified/utils/rules.test.ts
index 4b4036c40d2..63c0cb62f04 100644
--- a/public/app/features/alerting/unified/utils/rules.test.ts
+++ b/public/app/features/alerting/unified/utils/rules.test.ts
@@ -55,6 +55,7 @@ describe('getRuleOrigin', () => {
addedComponents: [],
extensionPoints: [],
exposedComponents: [],
+ addedFunctions: [],
},
dependencies: {
grafanaVersion: '',
diff --git a/public/app/features/alerting/unified/withPageErrorBoundary.tsx b/public/app/features/alerting/unified/withPageErrorBoundary.tsx
new file mode 100644
index 00000000000..e53d5bb2e0d
--- /dev/null
+++ b/public/app/features/alerting/unified/withPageErrorBoundary.tsx
@@ -0,0 +1,25 @@
+import { ComponentType } from 'react';
+
+import { ErrorBoundaryAlertProps, withErrorBoundary } from '@grafana/ui';
+
+import { logError } from './Analytics';
+
+/**
+ * HOC for wrapping alerting page in an error boundary.
+ * It provides alerting-specific error handling.
+ *
+ * @param Component - the react component to wrap in error boundary
+ * @param errorBoundaryProps - error boundary options
+ *
+ * @public
+ */
+export function withPageErrorBoundary(
+ Component: ComponentType
,
+ errorBoundaryProps: Omit = {}
+): ComponentType {
+ return withErrorBoundary(Component, {
+ ...errorBoundaryProps,
+ style: 'page',
+ errorLogger: logError,
+ });
+}
diff --git a/public/app/features/auth-config/utils/data.ts b/public/app/features/auth-config/utils/data.ts
index c0beae9ec27..00a3dd84453 100644
--- a/public/app/features/auth-config/utils/data.ts
+++ b/public/app/features/auth-config/utils/data.ts
@@ -56,7 +56,10 @@ const strToValue = (val: string | string[]): SelectableValue[] => {
}
// Stored as JSON Array
if (val.startsWith('[') && val.endsWith(']')) {
- return JSON.parse(val).map((v: string) => ({ label: v, value: v }));
+ // Fallback to parsing it like a non-json string if it is not valid json, instead of crashing.
+ try {
+ return JSON.parse(val).map((v: string) => ({ label: v, value: v }));
+ } catch {}
}
return val.split(/[\s,]/).map((s) => ({ label: s, value: s }));
diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts
index 7bfb8f3fe2e..1d6c39dba0d 100644
--- a/public/app/features/canvas/element.ts
+++ b/public/app/features/canvas/element.ts
@@ -1,6 +1,6 @@
import { ComponentType } from 'react';
-import { DataLink, RegistryItem, OneClickMode, Action } from '@grafana/data';
+import { DataLink, RegistryItem, Action } from '@grafana/data';
import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
import { ColorDimensionConfig, ScaleDimensionConfig } from '@grafana/schema';
import { config } from 'app/core/config';
@@ -34,7 +34,6 @@ export interface CanvasElementOptions {
connections?: CanvasConnection[];
links?: DataLink[];
actions?: Action[];
- oneClickMode?: OneClickMode;
}
// Unit is percentage from the middle of the element
diff --git a/public/app/features/canvas/elements/cloud.tsx b/public/app/features/canvas/elements/cloud.tsx
index 56695ef63b4..cff34c95580 100644
--- a/public/app/features/canvas/elements/cloud.tsx
+++ b/public/app/features/canvas/elements/cloud.tsx
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { v4 as uuidv4 } from 'uuid';
-import { GrafanaTheme2, OneClickMode } from '@grafana/data';
+import { GrafanaTheme2 } from '@grafana/data';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -99,7 +99,6 @@ export const cloudItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
- oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
diff --git a/public/app/features/canvas/elements/droneFront.tsx b/public/app/features/canvas/elements/droneFront.tsx
index b535ea52739..db24a5cfa1b 100644
--- a/public/app/features/canvas/elements/droneFront.tsx
+++ b/public/app/features/canvas/elements/droneFront.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
-import { GrafanaTheme2, OneClickMode } from '@grafana/data';
+import { GrafanaTheme2 } from '@grafana/data';
import { ScalarDimensionConfig } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions';
@@ -94,7 +94,6 @@ export const droneFrontItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
- oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
diff --git a/public/app/features/canvas/elements/droneSide.tsx b/public/app/features/canvas/elements/droneSide.tsx
index ec6d82082c1..6f02e016953 100644
--- a/public/app/features/canvas/elements/droneSide.tsx
+++ b/public/app/features/canvas/elements/droneSide.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
-import { GrafanaTheme2, OneClickMode } from '@grafana/data';
+import { GrafanaTheme2 } from '@grafana/data';
import { ScalarDimensionConfig } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions';
@@ -93,7 +93,6 @@ export const droneSideItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
- oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
diff --git a/public/app/features/canvas/elements/droneTop.tsx b/public/app/features/canvas/elements/droneTop.tsx
index dcdc96ef264..9d681aa5901 100644
--- a/public/app/features/canvas/elements/droneTop.tsx
+++ b/public/app/features/canvas/elements/droneTop.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
-import { GrafanaTheme2, OneClickMode } from '@grafana/data';
+import { GrafanaTheme2 } from '@grafana/data';
import { ScalarDimensionConfig } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions';
@@ -98,7 +98,6 @@ export const droneTopItem: CanvasElementItem = {
fixed: 'transparent',
},
},
- oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
diff --git a/public/app/features/canvas/elements/ellipse.tsx b/public/app/features/canvas/elements/ellipse.tsx
index fe8a4c286ee..11de4f65e2f 100644
--- a/public/app/features/canvas/elements/ellipse.tsx
+++ b/public/app/features/canvas/elements/ellipse.tsx
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { v4 as uuidv4 } from 'uuid';
-import { GrafanaTheme2, OneClickMode } from '@grafana/data';
+import { GrafanaTheme2 } from '@grafana/data';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -106,7 +106,6 @@ export const ellipseItem: CanvasElementItem = {
left: options?.placement?.left ?? 100,
rotation: options?.placement?.rotation ?? 0,
},
- oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
diff --git a/public/app/features/canvas/elements/metricValue.tsx b/public/app/features/canvas/elements/metricValue.tsx
index ec84f383f18..0ddec47734a 100644
--- a/public/app/features/canvas/elements/metricValue.tsx
+++ b/public/app/features/canvas/elements/metricValue.tsx
@@ -3,13 +3,7 @@ import { useCallback } from 'react';
import { useObservable } from 'react-use';
import { of } from 'rxjs';
-import {
- DataFrame,
- FieldNamePickerConfigSettings,
- GrafanaTheme2,
- OneClickMode,
- StandardEditorsRegistryItem,
-} from '@grafana/data';
+import { DataFrame, FieldNamePickerConfigSettings, GrafanaTheme2, StandardEditorsRegistryItem } from '@grafana/data';
import { TextDimensionMode } from '@grafana/schema';
import { usePanelContext, useStyles2 } from '@grafana/ui';
import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker';
@@ -181,7 +175,6 @@ export const metricValueItem: CanvasElementItem = {
left: options?.placement?.left ?? 100,
rotation: options?.placement?.rotation ?? 0,
},
- oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
diff --git a/public/app/features/canvas/elements/parallelogram.tsx b/public/app/features/canvas/elements/parallelogram.tsx
index fc46b2f09fd..3a163dd5503 100644
--- a/public/app/features/canvas/elements/parallelogram.tsx
+++ b/public/app/features/canvas/elements/parallelogram.tsx
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { v4 as uuidv4 } from 'uuid';
-import { GrafanaTheme2, OneClickMode } from '@grafana/data';
+import { GrafanaTheme2 } from '@grafana/data';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -99,7 +99,6 @@ export const parallelogramItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
- oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
diff --git a/public/app/features/canvas/elements/rectangle.tsx b/public/app/features/canvas/elements/rectangle.tsx
index d7455b450dd..08265b8deda 100644
--- a/public/app/features/canvas/elements/rectangle.tsx
+++ b/public/app/features/canvas/elements/rectangle.tsx
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { PureComponent } from 'react';
-import { GrafanaTheme2, OneClickMode } from '@grafana/data';
+import { GrafanaTheme2 } from '@grafana/data';
import { stylesFactory } from '@grafana/ui';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
@@ -72,7 +72,6 @@ export const rectangleItem: CanvasElementItem = {
fixed: defaultBgColor,
},
},
- oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
diff --git a/public/app/features/canvas/elements/server/server.tsx b/public/app/features/canvas/elements/server/server.tsx
index 32665e9c0b8..b0650dbc068 100644
--- a/public/app/features/canvas/elements/server/server.tsx
+++ b/public/app/features/canvas/elements/server/server.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
-import { GrafanaTheme2, LinkModel, OneClickMode } from '@grafana/data';
+import { GrafanaTheme2, LinkModel } from '@grafana/data';
import { ColorDimensionConfig, ScalarDimensionConfig } from '@grafana/schema';
import config from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions';
@@ -83,7 +83,6 @@ export const serverItem: CanvasElementItem = {
config: {
type: ServerType.Single,
},
- oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
diff --git a/public/app/features/canvas/elements/text.tsx b/public/app/features/canvas/elements/text.tsx
index ef76d3f385e..e524163b472 100644
--- a/public/app/features/canvas/elements/text.tsx
+++ b/public/app/features/canvas/elements/text.tsx
@@ -4,7 +4,7 @@ import * as React from 'react';
import { useObservable } from 'react-use';
import { of } from 'rxjs';
-import { DataFrame, GrafanaTheme2, OneClickMode } from '@grafana/data';
+import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { Input, usePanelContext, useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -148,7 +148,6 @@ export const textItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
- oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
diff --git a/public/app/features/canvas/elements/triangle.tsx b/public/app/features/canvas/elements/triangle.tsx
index b709eab1e99..cf640d21a37 100644
--- a/public/app/features/canvas/elements/triangle.tsx
+++ b/public/app/features/canvas/elements/triangle.tsx
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { v4 as uuidv4 } from 'uuid';
-import { GrafanaTheme2, OneClickMode } from '@grafana/data';
+import { GrafanaTheme2 } from '@grafana/data';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -100,7 +100,6 @@ export const triangleItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
- oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
diff --git a/public/app/features/canvas/elements/windTurbine.tsx b/public/app/features/canvas/elements/windTurbine.tsx
index 179644b4e7b..ed3be434249 100644
--- a/public/app/features/canvas/elements/windTurbine.tsx
+++ b/public/app/features/canvas/elements/windTurbine.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
-import { GrafanaTheme2, LinkModel, OneClickMode } from '@grafana/data';
+import { GrafanaTheme2, LinkModel } from '@grafana/data';
import { ScalarDimensionConfig } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions';
@@ -90,7 +90,6 @@ export const windTurbineItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
- oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx
index 5297eb2b470..f61e9d94742 100644
--- a/public/app/features/canvas/runtime/element.tsx
+++ b/public/app/features/canvas/runtime/element.tsx
@@ -2,8 +2,18 @@ import * as React from 'react';
import { CSSProperties } from 'react';
import { OnDrag, OnResize, OnRotate } from 'react-moveable/declaration/types';
-import { FieldType, getLinksSupplier, LinkModel, OneClickMode, ScopedVars, ValueLinkConfig } from '@grafana/data';
+import {
+ FieldType,
+ getLinksSupplier,
+ LinkModel,
+ ScopedVars,
+ ValueLinkConfig,
+ OneClickMode,
+ ActionModel,
+} from '@grafana/data';
+import { ConfirmModal } from '@grafana/ui';
import { LayerElement } from 'app/core/components/Layers/types';
+import { t } from 'app/core/internationalization';
import { notFoundItem } from 'app/features/canvas/elements/notFound';
import { DimensionContext } from 'app/features/dimensions';
import {
@@ -46,6 +56,10 @@ export class ElementState implements LayerElement {
getLinks?: (config: ValueLinkConfig) => LinkModel[];
+ // cached for tooltips/mousemove
+ oneClickMode = OneClickMode.Off;
+ showConfirmation = false;
+
constructor(
public item: CanvasElementItem,
public options: CanvasElementOptions,
@@ -63,7 +77,7 @@ export class ElementState implements LayerElement {
options.placement = options.placement ?? { width: 100, height: 100, top: 0, left: 0, rotation: 0 };
options.background = options.background ?? { color: { fixed: 'transparent' } };
options.border = options.border ?? { color: { fixed: 'dark-green' } };
- options.oneClickMode = options.oneClickMode ?? OneClickMode.Off;
+
const scene = this.getScene();
if (!options.name) {
const newName = scene?.getNextElementName();
@@ -376,6 +390,12 @@ export class ElementState implements LayerElement {
this.options.links = this.options.links?.filter((link) => link !== null);
+ if (this.options.links?.some((link) => link.oneClick === true)) {
+ this.oneClickMode = OneClickMode.Link;
+ } else if (this.options.actions?.some((action) => action.oneClick === true)) {
+ this.oneClickMode = OneClickMode.Action;
+ }
+
if (frames) {
const defaultField = {
name: 'Default field',
@@ -588,31 +608,26 @@ export class ElementState implements LayerElement {
handleMouseEnter = (event: React.MouseEvent, isSelected: boolean | undefined) => {
const scene = this.getScene();
- const shouldHandleTooltip =
- !scene?.isEditingEnabled && !scene?.tooltip?.isOpen && this.options.oneClickMode === OneClickMode.Off;
+ const shouldHandleTooltip = !scene?.isEditingEnabled && !scene?.tooltip?.isOpen;
if (shouldHandleTooltip) {
this.handleTooltip(event);
} else if (!isSelected) {
scene?.connections.handleMouseEnter(event);
}
- const shouldHandleOneClickLink =
- this.options.oneClickMode === OneClickMode.Link && this.options.links && this.options.links.length > 0;
-
- const shouldHandleOneClickAction =
- this.options.oneClickMode === OneClickMode.Action && this.options.actions && this.options.actions.length > 0;
-
- if (shouldHandleOneClickLink && this.div) {
- const primaryDataLink = this.getPrimaryDataLink();
- if (primaryDataLink) {
- this.div.style.cursor = 'pointer';
- this.div.title = `Navigate to ${primaryDataLink.title === '' ? 'data link' : primaryDataLink.title}`;
- }
- } else if (shouldHandleOneClickAction && this.div) {
- const primaryAction = this.getPrimaryAction();
- if (primaryAction) {
- this.div.style.cursor = 'pointer';
- this.div.title = primaryAction.title;
+ if (this.div != null) {
+ if (this.oneClickMode === OneClickMode.Link) {
+ const primaryDataLink = this.getPrimaryDataLink();
+ if (primaryDataLink) {
+ this.div.style.cursor = 'pointer';
+ this.div.title = `Navigate to ${primaryDataLink.title === '' ? 'data link' : primaryDataLink.title}`;
+ }
+ } else if (this.oneClickMode === OneClickMode.Action) {
+ const primaryAction = this.getPrimaryAction();
+ if (primaryAction) {
+ this.div.style.cursor = 'pointer';
+ this.div.title = primaryAction.title;
+ }
}
}
};
@@ -620,7 +635,7 @@ export class ElementState implements LayerElement {
getPrimaryDataLink = () => {
if (this.getLinks) {
const links = this.getLinks({ valueRowIndex: getRowIndex(this.data.field, this.getScene()!) });
- return links[0];
+ return links.find((link) => link.oneClick === true);
}
return undefined;
@@ -652,7 +667,7 @@ export class ElementState implements LayerElement {
actionsDefaultFieldConfig.actions,
config
);
- return actions[0];
+ return actions.find((action) => action.oneClick === true);
}
return undefined;
@@ -672,11 +687,11 @@ export class ElementState implements LayerElement {
handleMouseLeave = (event: React.MouseEvent) => {
const scene = this.getScene();
- if (scene?.tooltipCallback && !scene?.tooltip?.isOpen && this.options.oneClickMode === OneClickMode.Off) {
+ if (scene?.tooltipCallback && !scene?.tooltip?.isOpen) {
scene.tooltipCallback(undefined);
}
- if (this.options.oneClickMode !== OneClickMode.Off && this.div) {
+ if (this.oneClickMode !== OneClickMode.Off && this.div) {
this.div.style.cursor = 'auto';
this.div.title = '';
}
@@ -684,16 +699,14 @@ export class ElementState implements LayerElement {
onElementClick = (event: React.MouseEvent) => {
// If one-click access is enabled, open the primary link
- if (this.options.oneClickMode === OneClickMode.Link) {
+ if (this.oneClickMode === OneClickMode.Link) {
let primaryDataLink = this.getPrimaryDataLink();
if (primaryDataLink) {
window.open(primaryDataLink.href, primaryDataLink.target ?? '_self');
}
- } else if (this.options.oneClickMode === OneClickMode.Action) {
- let primaryAction = this.getPrimaryAction();
- if (primaryAction && primaryAction.onClick) {
- primaryAction.onClick(event);
- }
+ } else if (this.oneClickMode === OneClickMode.Action) {
+ this.showConfirmation = true;
+ this.forceUpdate();
} else {
this.handleTooltip(event);
this.onTooltipCallback();
@@ -721,29 +734,68 @@ export class ElementState implements LayerElement {
}
};
+ forceUpdate = () => {
+ const scene = this.getScene();
+ if (scene?.actionConfirmationCallback) {
+ scene.actionConfirmationCallback();
+ }
+ };
+
+ renderActionsConfirmModal = (action: ActionModel | undefined) => {
+ if (!action) {
+ return;
+ }
+
+ return (
+ <>
+ {this.showConfirmation && action && (
+ {
+ this.showConfirmation = false;
+ action.onClick(new MouseEvent('click'));
+ this.forceUpdate();
+ }}
+ onDismiss={() => {
+ this.showConfirmation = false;
+ this.forceUpdate();
+ }}
+ />
+ )}
+ >
+ );
+ };
+
render() {
const { item, div } = this;
const scene = this.getScene();
const isSelected = div && scene && scene.selecto && scene.selecto.getSelectedTargets().includes(div);
return (
- this.handleMouseEnter(e, isSelected)}
- onMouseLeave={!scene?.isEditingEnabled ? this.handleMouseLeave : undefined}
- onClick={!scene?.isEditingEnabled ? this.onElementClick : undefined}
- onKeyDown={!scene?.isEditingEnabled ? this.onElementKeyDown : undefined}
- role="button"
- tabIndex={0}
- >
-
-
+ <>
+ this.handleMouseEnter(e, isSelected)}
+ onMouseLeave={!scene?.isEditingEnabled ? this.handleMouseLeave : undefined}
+ onClick={!scene?.isEditingEnabled ? this.onElementClick : undefined}
+ onKeyDown={!scene?.isEditingEnabled ? this.onElementKeyDown : undefined}
+ role="button"
+ tabIndex={0}
+ >
+
+
+ {this.showConfirmation && this.renderActionsConfirmModal(this.getPrimaryAction())}
+ >
);
}
}
diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx
index 902cc24640e..d0fd1262aca 100644
--- a/public/app/features/canvas/runtime/scene.tsx
+++ b/public/app/features/canvas/runtime/scene.tsx
@@ -97,6 +97,8 @@ export class Scene {
moveableActionCallback?: (moved: boolean) => void;
+ actionConfirmationCallback?: () => void;
+
readonly editModeEnabled = new BehaviorSubject(false);
subscription: Subscription;
diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
index e95ede9eebc..9f02500d65e 100644
--- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
+++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
@@ -40,7 +40,8 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
} else {
stateManager.loadDashboard({
uid: (route.routeName === DashboardRoutes.Provisioning ? path : uid) ?? '',
- slug: slug,
+ type,
+ slug,
route: route.routeName as DashboardRoutes,
urlFolderUid: queryParams.folderUid,
});
diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts
index 2e46767d485..a70fa61ab05 100644
--- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts
+++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts
@@ -661,7 +661,6 @@ const customHomeDashboardV2Spec = {
to: 'now',
autoRefresh: '',
autoRefreshIntervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
- quickRanges: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'],
hideTimepicker: false,
fiscalYearStartMonth: 0,
},
diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts
index db5a64048c7..9426cf3e7a6 100644
--- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts
+++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts
@@ -267,11 +267,12 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
}
public async fetchDashboard({
+ type,
+ slug,
uid,
route,
urlFolderUid,
params,
- slug,
}: LoadDashboardOptions): Promise {
const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid;
@@ -328,7 +329,7 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
}
: undefined;
- rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid, queryParams);
+ rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid, queryParams);
if (route === DashboardRoutes.Embedded) {
rsp.meta.isEmbedded = true;
@@ -482,6 +483,8 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
}
public async fetchDashboard({
+ type,
+ slug,
uid,
route,
urlFolderUid,
@@ -534,7 +537,7 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
...params.variables,
}
: undefined;
- rsp = await this.dashboardLoader.loadDashboard('db', '', uid, queryParams);
+ rsp = await this.dashboardLoader.loadDashboard(type || 'db', slug || '', uid, queryParams);
if (route === DashboardRoutes.Embedded) {
throw new Error('Method not implemented.');
// rsp.meta.isEmbedded = true;
diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
index 3f9c1b5910a..d760f81f225 100644
--- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
@@ -30,6 +30,7 @@ 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 { sortedDeepCloneWithoutNulls } from 'app/core/utils/object';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
@@ -675,7 +676,7 @@ export class DashboardScene extends SceneObjectBase {
saveModel?: Dashboard | DashboardV2Spec,
meta?: DashboardMeta | DashboardWithAccessInfo['metadata']
): void {
- this._serializer.initialSaveModel = saveModel;
+ this._serializer.initialSaveModel = sortedDeepCloneWithoutNulls(saveModel);
this._serializer.metadata = meta;
}
diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts
index 3493be625f9..cde4c8df0e0 100644
--- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts
+++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts
@@ -22,7 +22,8 @@ import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutMana
import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent';
export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
- private _eventSub?: Unsubscribable;
+ private _viewEventSub?: Unsubscribable;
+ private _inspectEventSub?: Unsubscribable;
constructor(private _scene: DashboardScene) {}
@@ -78,6 +79,14 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
if (typeof values.inspect === 'string') {
let panel = findVizPanelByKey(this._scene, values.inspect);
if (!panel) {
+ // If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet
+ // Here we check if the key contains the clone key so we force the repeat processing
+ // It doesn't matter if the element or the ancestors are clones or not, just that the key contains the clone key
+ if (containsCloneKey(values.inspect)) {
+ this._handleInspectRepeatClone(values.inspect);
+ return;
+ }
+
appEvents.emit(AppEvents.alertError, ['Panel not found']);
locationService.partial({ inspect: null });
return;
@@ -177,12 +186,27 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
}
}
+ private _handleInspectRepeatClone(inspect: string) {
+ if (!this._inspectEventSub) {
+ this._inspectEventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => {
+ const panel = findVizPanelByKey(this._scene, inspect);
+ if (panel) {
+ this._inspectEventSub?.unsubscribe();
+ this._scene.setState({
+ inspectPanelKey: inspect,
+ overlay: new PanelInspectDrawer({ panelRef: panel.getRef() }),
+ });
+ }
+ });
+ }
+ }
+
private _handleViewRepeatClone(viewPanel: string) {
- if (!this._eventSub) {
- this._eventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => {
+ if (!this._viewEventSub) {
+ this._viewEventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => {
const panel = findVizPanelByKey(this._scene, viewPanel);
if (panel) {
- this._eventSub?.unsubscribe();
+ this._viewEventSub?.unsubscribe();
this._scene.setState({ viewPanelScene: new ViewPanelScene({ panelRef: panel.getRef() }) });
}
});
diff --git a/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx b/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx
index 780fab43b99..2026f777a6a 100644
--- a/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx
+++ b/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import classNames from 'classnames';
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneGridRow, VizPanel, sceneGraph } from '@grafana/scenes';
@@ -12,6 +12,7 @@ import { forceActivateFullSceneObjectTree } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
+import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent';
export interface Props {
dashboard: DashboardScene;
@@ -25,6 +26,7 @@ export function PanelSearchLayout({ dashboard, panelSearch = '', panelsPerRow }:
const { body } = dashboard.state;
const filteredPanels: VizPanel[] = [];
const styles = useStyles2(getStyles);
+ const [_, setRepeatsUpdated] = useState('');
const bodyGrid = body instanceof DefaultGridLayoutManager ? body.state.grid : null;
@@ -34,11 +36,11 @@ export function PanelSearchLayout({ dashboard, panelSearch = '', panelsPerRow }:
for (const gridItem of bodyGrid.state.children) {
if (gridItem instanceof DashboardGridItem) {
- filterPanels(gridItem, dashboard, panelSearch, filteredPanels);
+ filterPanels(gridItem, dashboard, panelSearch, filteredPanels, setRepeatsUpdated);
} else if (gridItem instanceof SceneGridRow) {
for (const rowItem of gridItem.state.children) {
if (rowItem instanceof DashboardGridItem) {
- filterPanels(rowItem, dashboard, panelSearch, filteredPanels);
+ filterPanels(rowItem, dashboard, panelSearch, filteredPanels, setRepeatsUpdated);
}
}
}
@@ -98,7 +100,8 @@ function filterPanels(
gridItem: DashboardGridItem,
dashboard: DashboardScene,
searchString: string,
- filteredPanels: VizPanel[]
+ filteredPanels: VizPanel[],
+ setRepeatsUpdated: (updated: string) => void
) {
const interpolatedSearchString = sceneGraph.interpolate(dashboard, searchString).toLowerCase();
@@ -107,6 +110,12 @@ function filterPanels(
const panel = gridItem.state.body;
const interpolatedTitle = panel.interpolate(panel.state.title, undefined, 'text').toLowerCase();
if (interpolatedTitle.includes(interpolatedSearchString)) {
+ gridItem.subscribeToEvent(DashboardRepeatsProcessedEvent, (event) => {
+ const source = event.payload.source;
+ if (source instanceof DashboardGridItem) {
+ setRepeatsUpdated(event.payload.source.state.key ?? '');
+ }
+ });
gridItem.activate();
}
}
diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx
index 97b268757d9..bad747f55de 100644
--- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx
@@ -252,6 +252,14 @@ export class DefaultGridLayoutManager
}
public activateRepeaters() {
+ if (!this.isActive) {
+ this.activate();
+ }
+
+ if (!this.state.grid.isActive) {
+ this.state.grid.activate();
+ }
+
this.state.grid.forEachChild((child) => {
if (child instanceof DashboardGridItem && !child.isActive) {
child.activate();
diff --git a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx
index 2e728f25a39..843f58b5640 100644
--- a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx
@@ -160,6 +160,51 @@ describe('RowRepeaterBehavior', () => {
});
});
+ describe('Given scene with variable with 15 values', () => {
+ let scene: DashboardScene, grid: SceneGridLayout;
+ let gridStateUpdates: unknown[];
+
+ beforeEach(async () => {
+ ({ scene, grid } = buildScene({ variableQueryTime: 0 }, [
+ { label: 'A', value: 'A1' },
+ { label: 'B', value: 'B1' },
+ { label: 'C', value: 'C1' },
+ { label: 'D', value: 'D1' },
+ { label: 'E', value: 'E1' },
+ { label: 'F', value: 'F1' },
+ { label: 'G', value: 'G1' },
+ { label: 'H', value: 'H1' },
+ { label: 'I', value: 'I1' },
+ { label: 'J', value: 'J1' },
+ { label: 'K', value: 'K1' },
+ { label: 'L', value: 'L1' },
+ { label: 'M', value: 'M1' },
+ { label: 'N', value: 'N1' },
+ { label: 'O', value: 'O1' },
+ ]));
+
+ gridStateUpdates = [];
+ grid.subscribeToState((state) => gridStateUpdates.push(state));
+
+ activateFullSceneTree(scene);
+ await new Promise((r) => setTimeout(r, 1));
+ });
+
+ it('Should handle second repeat cycle and update remove old repeats', async () => {
+ // should have 15 repeated rows (and the panel above + the row at the bottom)
+ expect(grid.state.children.length).toBe(17);
+
+ // trigger another repeat cycle by changing the variable
+ const variable = scene.state.$variables!.state.variables[0] as TestVariable;
+ variable.changeValueTo(['B1', 'C1']);
+
+ await new Promise((r) => setTimeout(r, 1));
+
+ // should now only have 2 repeated rows (and the panel above + the row at the bottom)
+ expect(grid.state.children.length).toBe(4);
+ });
+ });
+
describe('Given scene empty row', () => {
let scene: DashboardScene;
let grid: SceneGridLayout;
diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx
index d152fed3a64..ec5b4710089 100644
--- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx
@@ -104,6 +104,51 @@ describe('RowItemRepeaterBehavior', () => {
});
});
+ describe('Given scene with variable with 15 values', () => {
+ let scene: DashboardScene, layout: RowsLayoutManager;
+ let layoutStateUpdates: unknown[];
+
+ beforeEach(async () => {
+ ({ scene, layout } = buildScene({ variableQueryTime: 0 }, [
+ { label: 'A', value: 'A1' },
+ { label: 'B', value: 'B1' },
+ { label: 'C', value: 'C1' },
+ { label: 'D', value: 'D1' },
+ { label: 'E', value: 'E1' },
+ { label: 'F', value: 'F1' },
+ { label: 'G', value: 'G1' },
+ { label: 'H', value: 'H1' },
+ { label: 'I', value: 'I1' },
+ { label: 'J', value: 'J1' },
+ { label: 'K', value: 'K1' },
+ { label: 'L', value: 'L1' },
+ { label: 'M', value: 'M1' },
+ { label: 'N', value: 'N1' },
+ { label: 'O', value: 'O1' },
+ ]));
+
+ layoutStateUpdates = [];
+ layout.subscribeToState((state) => layoutStateUpdates.push(state));
+
+ activateFullSceneTree(scene);
+ await new Promise((r) => setTimeout(r, 1));
+ });
+
+ it('Should handle second repeat cycle and update remove old repeats', async () => {
+ // should have 15 repeated rows (and the panel above)
+ expect(layout.state.rows.length).toBe(16);
+
+ // trigger another repeat cycle by changing the variable
+ const variable = scene.state.$variables!.state.variables[0] as TestVariable;
+ variable.changeValueTo(['B1', 'C1']);
+
+ await new Promise((r) => setTimeout(r, 1));
+
+ // should now only have 2 repeated rows (and the panel above)
+ expect(layout.state.rows.length).toBe(3);
+ });
+ });
+
describe('Given a scene with empty variable', () => {
it('Should preserve repeat row', async () => {
const { scene, layout } = buildScene({ variableQueryTime: 0 }, []);
diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts
index 8f09578ae54..74d58c10719 100644
--- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts
+++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts
@@ -36,6 +36,26 @@ jest.mock('@grafana/runtime', () => ({
getInstanceSettings: jest.fn(),
};
},
+ config: {
+ ...jest.requireActual('@grafana/runtime').config,
+ bootData: {
+ settings: {
+ defaultDatasource: '-- Grafana --',
+ datasources: {
+ '-- Grafana --': {
+ name: 'Grafana',
+ meta: { id: 'grafana' },
+ type: 'datasource',
+ },
+ prometheus: {
+ name: 'prometheus',
+ meta: { id: 'prometheus' },
+ type: 'datasource',
+ },
+ },
+ },
+ },
+ },
}));
describe('DashboardSceneSerializer', () => {
@@ -584,7 +604,6 @@ describe('DashboardSceneSerializer', () => {
to: '',
autoRefresh: '',
autoRefreshIntervals: [],
- quickRanges: [],
hideTimepicker: false,
fiscalYearStartMonth: 0,
timezone: '',
@@ -646,7 +665,6 @@ describe('DashboardSceneSerializer', () => {
from: 'now-1h',
hideTimepicker: false,
nowDelay: undefined,
- quickRanges: [],
timezone: 'browser',
to: 'now',
});
diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts
index 039f366de9a..442b8356f91 100644
--- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts
+++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts
@@ -4,6 +4,7 @@ import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alp
import { AnnoKeyDashboardSnapshotOriginalUrl } from 'app/features/apiserver/types';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types';
+import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator';
import {
getPanelPluginCounts,
getV1SchemaVariables,
@@ -185,7 +186,7 @@ export class V2DashboardSerializer
if (this.initialSaveModel) {
return {
- schemaVersion: 40,
+ schemaVersion: DASHBOARD_SCHEMA_VERSION,
uid: s.state.uid,
title: this.initialSaveModel.title,
panels_count: panelPluginIds.length || 0,
diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap
index 8ab27fc7a70..8146499446c 100644
--- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap
+++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap
@@ -280,7 +280,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
],
"preload": false,
"refresh": "",
- "schemaVersion": 40,
+ "schemaVersion": 41,
"tags": [
"templating",
"gdev",
@@ -548,7 +548,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho
],
"preload": false,
"refresh": "5m",
- "schemaVersion": 40,
+ "schemaVersion": 41,
"tags": [
"tag1",
"tag2",
@@ -906,7 +906,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
],
"preload": false,
"refresh": "",
- "schemaVersion": 40,
+ "schemaVersion": 41,
"tags": [
"gdev",
"graph-ng",
diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap
index 7695bba0ab6..41482314925 100644
--- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap
+++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap
@@ -195,7 +195,6 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
"from": "now-1h",
"hideTimepicker": false,
"nowDelay": "1m",
- "quickRanges": [],
"timezone": "UTC",
"to": "now",
"weekStart": "monday",
diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
index 90033cc9856..108a1e1d159 100644
--- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
@@ -293,7 +293,7 @@ describe('transformSaveModelSchemaV2ToScene', () => {
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.uid).toBe(MIXED_DATASOURCE_NAME);
});
- it('should set panel ds as undefined if it is not mixed DS', () => {
+ it('should set ds if it is not mixed DS', () => {
const dashboard = cloneDeep(defaultDashboard);
getPanelElement(dashboard.spec, 'panel-1')?.spec.data.spec.queries.push({
kind: 'PanelQuery',
@@ -317,10 +317,13 @@ describe('transformSaveModelSchemaV2ToScene', () => {
const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels();
expect(vizPanels.length).toBe(3);
- expect(getQueryRunnerFor(vizPanels[0])?.state.datasource).toBeUndefined();
+ expect(getQueryRunnerFor(vizPanels[0])?.state.queries[0].datasource).toEqual({
+ type: 'prometheus',
+ uid: 'datasource1',
+ });
});
- it('should set panel ds as mixed if one ds is undefined', () => {
+ it('should set panel ds as mixed if no panels have ds defined', () => {
const dashboard = cloneDeep(defaultDashboard);
getPanelElement(dashboard.spec, 'panel-1')?.spec.data.spec.queries.push({
diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
index a8f97ad4287..40b5b01fbf9 100644
--- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
@@ -234,7 +234,7 @@ function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined {
}
});
- return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : undefined;
+ return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : datasource;
}
function panelQueryKindToSceneQuery(query: PanelQueryKind): SceneDataQuery {
diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
index fbe3b3c4c0d..d9576c47fd0 100644
--- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
@@ -185,7 +185,6 @@ describe('transformSceneToSaveModel', () => {
timepicker: {
...dashboard_to_load1.timepicker,
refresh_intervals: ['5m', '15m', '30m', '1h'],
- time_options: ['5m', '15m', '30m'],
hidden: true,
},
links: [{ ...NEW_LINK, title: 'Link 1' }],
diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts
index bd8ed9f3fac..3672567fdb9 100644
--- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts
@@ -11,6 +11,7 @@ import {
VizPanel,
} from '@grafana/scenes';
import { DataSourceRef } from '@grafana/schema';
+import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object';
import {
DashboardV2Spec,
@@ -73,7 +74,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
const dashboardSchemaV2: DeepPartial = {
//dashboard settings
title: sceneDash.title,
- description: sceneDash.description ?? '',
+ description: sceneDash.description,
cursorSync: getCursorSync(sceneDash),
liveNow: getLiveNow(sceneDash),
preload: sceneDash.preload,
@@ -89,7 +90,6 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
to: timeRange.to,
autoRefresh: refreshPicker?.state.refresh || '',
autoRefreshIntervals: refreshPicker?.state.intervals,
- quickRanges: [], //FIXME is coming timepicker.time_options,
hideTimepicker: controlsState?.hideTimeControls ?? false,
weekStart: timeRange.weekStart,
fiscalYearStartMonth: timeRange.fiscalYearStartMonth,
@@ -117,7 +117,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
try {
// validateDashboardSchemaV2 will throw an error if the dashboard is not valid
if (validateDashboardSchemaV2(dashboardSchemaV2)) {
- return dashboardSchemaV2;
+ return sortedDeepCloneWithoutNulls(dashboardSchemaV2);
}
// should never reach this point, validation should throw an error
throw new Error('Error we could transform the dashboard to schema v2: ' + dashboardSchemaV2);
@@ -242,7 +242,7 @@ function getVizPanelQueries(vizPanel: VizPanel): PanelQueryKind[] {
const queries: PanelQueryKind[] = [];
const queryRunner = getQueryRunnerFor(vizPanel);
const vizPanelQueries = queryRunner?.state.queries;
- const datasource = queryRunner?.state.datasource;
+ const datasource = queryRunner?.state.datasource ?? getDefaultDataSourceRef();
if (vizPanelQueries) {
vizPanelQueries.forEach((query) => {
@@ -251,7 +251,7 @@ function getVizPanelQueries(vizPanel: VizPanel): PanelQueryKind[] {
spec: omit(query, 'datasource', 'refId', 'hide'),
};
const querySpec: PanelQuerySpec = {
- datasource: datasource ?? getDefaultDataSourceRef(),
+ datasource: query.datasource ?? datasource,
query: dataQuery,
refId: query.refId,
hidden: Boolean(query.hide),
@@ -447,7 +447,7 @@ function validateDashboardSchemaV2(dash: unknown): dash is DashboardV2Spec {
if ('title' in dash && typeof dash.title !== 'string') {
throw new Error('Title is not a string');
}
- if ('description' in dash && typeof dash.description !== 'string') {
+ if ('description' in dash && dash.description !== undefined && typeof dash.description !== 'string') {
throw new Error('Description is not a string');
}
if ('cursorSync' in dash && typeof dash.cursorSync !== 'string') {
@@ -502,7 +502,11 @@ function validateDashboardSchemaV2(dash: unknown): dash is DashboardV2Spec {
if (!('autoRefreshIntervals' in dash.timeSettings) || !Array.isArray(dash.timeSettings.autoRefreshIntervals)) {
throw new Error('AutoRefreshIntervals is not an array');
}
- if (!('quickRanges' in dash.timeSettings) || !Array.isArray(dash.timeSettings.quickRanges)) {
+ if (
+ 'quickRanges' in dash.timeSettings &&
+ dash.timeSettings.quickRanges &&
+ !Array.isArray(dash.timeSettings.quickRanges)
+ ) {
throw new Error('QuickRanges is not an array');
}
if (!('hideTimepicker' in dash.timeSettings) || typeof dash.timeSettings.hideTimepicker !== 'boolean') {
diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.test.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.test.tsx
index 63339065a30..0a15aff1dd5 100644
--- a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.test.tsx
+++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.test.tsx
@@ -52,6 +52,7 @@ describe('VersionHistoryTable', () => {
version: mockVersions[1].version,
index: 1,
confirm: false,
+ timestamp: mockVersions[1].created,
});
});
});
diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx
index 3713f235106..48fc15963b8 100644
--- a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx
+++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx
@@ -70,6 +70,7 @@ export const VersionHistoryTable = ({ versions, canCompare, onCheck, onRestore }
version: version.version,
index: idx,
confirm: false,
+ timestamp: version.created,
});
}}
>
diff --git a/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts b/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts
index 3fbefb31c92..0b6de1db51a 100644
--- a/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts
+++ b/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts
@@ -166,7 +166,6 @@ export function restore(version: number, restoredFrom?: number) {
},
timepicker: {
refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
- time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'],
},
timezone: 'utc',
title: 'History Dashboard',
diff --git a/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx b/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx
index 265d32c9e72..01894fe9d9e 100644
--- a/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx
+++ b/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx
@@ -26,12 +26,12 @@ export interface Props extends GrafanaRouteComponentProps {
- stateManager.loadDashboard({ uid, route: DashboardRoutes.Embedded });
+ stateManager.loadDashboard({ uid, type, slug, route: DashboardRoutes.Embedded });
return () => stateManager.clearState();
- }, [stateManager, queryParams, uid]);
+ }, [stateManager, queryParams, uid, type, slug]);
if (!queryParams.panelId) {
return ;
@@ -64,7 +64,6 @@ export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: Dashboard
const [panel, error] = useSoloPanel(dashboard, panelId);
const { controls } = dashboard.useState();
const refreshPicker = controls?.useState()?.refreshPicker;
-
const styles = useStyles2(getStyles);
useEffect(() => {
diff --git a/public/app/features/dashboard-scene/utils/clone.test.ts b/public/app/features/dashboard-scene/utils/clone.test.ts
index 28441c4dbe1..97b0e377e6a 100644
--- a/public/app/features/dashboard-scene/utils/clone.test.ts
+++ b/public/app/features/dashboard-scene/utils/clone.test.ts
@@ -30,6 +30,8 @@ describe('clone', () => {
expect(getOriginalKey('panel-clone-1')).toBe('panel');
expect(getOriginalKey('row-clone-1/panel-clone-2')).toBe('panel');
expect(getOriginalKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe('panel');
+ expect(getOriginalKey('panel-2-clone-3')).toBe('panel-2');
+ expect(getOriginalKey('panel-2')).toBe('panel-2');
});
});
@@ -48,6 +50,20 @@ describe('clone', () => {
expect(isClonedKey('tab-clone-1/row-clone-2/panel')).toBe(false);
expect(isClonedKey('row-clone-1/panel')).toBe(false);
});
+
+ it('should properly handle indexes containing 0', () => {
+ expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-0')).toBe(false);
+ expect(isClonedKey('row-clone-0/panel-clone-0')).toBe(false);
+ expect(isClonedKey('panel-clone-0')).toBe(false);
+
+ expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-101')).toBe(true);
+ expect(isClonedKey('row-clone-0/panel-clone-101')).toBe(true);
+ expect(isClonedKey('panel-clone-1010')).toBe(true);
+
+ expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-10')).toBe(true);
+ expect(isClonedKey('row-clone-0/panel-clone-100')).toBe(true);
+ expect(isClonedKey('panel-clone-1000')).toBe(true);
+ });
});
describe('isClonedKeyOf', () => {
diff --git a/public/app/features/dashboard-scene/utils/clone.ts b/public/app/features/dashboard-scene/utils/clone.ts
index 4e5d2b79fab..05d9459e0e2 100644
--- a/public/app/features/dashboard-scene/utils/clone.ts
+++ b/public/app/features/dashboard-scene/utils/clone.ts
@@ -1,7 +1,7 @@
const CLONE_KEY = '-clone-';
const CLONE_SEPARATOR = '/';
-const CLONED_KEY_REGEX = new RegExp(`${CLONE_KEY}[1-9]+$`);
+const CLONED_KEY_REGEX = new RegExp(`${CLONE_KEY}[1-9][0-9]*$`);
const ORIGINAL_REGEX = new RegExp(`${CLONE_KEY}\\d+$`);
/**
diff --git a/public/app/features/dashboard-scene/utils/interactions.ts b/public/app/features/dashboard-scene/utils/interactions.ts
index ad1b3fbea0f..b52c09e190f 100644
--- a/public/app/features/dashboard-scene/utils/interactions.ts
+++ b/public/app/features/dashboard-scene/utils/interactions.ts
@@ -118,7 +118,7 @@ export const DashboardInteractions = {
},
// Dashboards versions interactions
- versionRestoreClicked: (properties: { version: number; index?: number; confirm: boolean }) => {
+ versionRestoreClicked: (properties: { version: number; index?: number; confirm: boolean; timestamp?: Date }) => {
reportDashboardInteraction('version_restore_clicked', properties);
},
showMoreVersionsClicked: () => {
diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts
index 27ecaac2fe2..5e52a1d517f 100644
--- a/public/app/features/dashboard-scene/utils/utils.ts
+++ b/public/app/features/dashboard-scene/utils/utils.ts
@@ -21,7 +21,7 @@ import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types/DashboardLayoutManager';
-import { getLastKeyFromClone, getOriginalKey } from './clone';
+import { getOriginalKey, isClonedKey } from './clone';
export const NEW_PANEL_HEIGHT = 8;
export const NEW_PANEL_WIDTH = 12;
@@ -64,7 +64,16 @@ function findVizPanelInternal(scene: SceneObject, key: string | undefined): VizP
const panel = sceneGraph.findObject(scene, (obj) => {
const objKey = obj.state.key!;
- if (objKey === key || getLastKeyFromClone(objKey) === getLastKeyFromClone(key) || getOriginalKey(objKey) === key) {
+ if (objKey === key) {
+ return true;
+ }
+
+ // It might be possible to have the keys changed in the meantime from `panel-2` to `panel-2-clone-0`
+ // We need to check this as well
+ const originalObjectKey = !isClonedKey(objKey) ? getOriginalKey(objKey) : objKey;
+ const originalKey = !isClonedKey(key) ? getOriginalKey(key) : key;
+
+ if (originalObjectKey === originalKey) {
return true;
}
diff --git a/public/app/features/dashboard/api/ResponseTransformers.test.ts b/public/app/features/dashboard/api/ResponseTransformers.test.ts
index 63cb8dc1e26..4a7a29cf711 100644
--- a/public/app/features/dashboard/api/ResponseTransformers.test.ts
+++ b/public/app/features/dashboard/api/ResponseTransformers.test.ts
@@ -113,8 +113,19 @@ describe('ResponseTransformers', () => {
timepicker: {
refresh_intervals: ['5s', '10s', '30s'],
hidden: false,
- time_options: ['5m', '15m', '1h'],
nowDelay: '1m',
+ quick_ranges: [
+ {
+ display: 'Last 6 hours',
+ from: 'now-6h',
+ to: 'now',
+ },
+ {
+ display: 'Last 7 days',
+ from: 'now-7d',
+ to: 'now',
+ },
+ ],
},
fiscalYearStartMonth: 1,
weekStart: 'monday',
@@ -462,7 +473,7 @@ describe('ResponseTransformers', () => {
expect(spec.timeSettings.autoRefresh).toBe(dashboardV1.refresh);
expect(spec.timeSettings.autoRefreshIntervals).toEqual(dashboardV1.timepicker?.refresh_intervals);
expect(spec.timeSettings.hideTimepicker).toBe(dashboardV1.timepicker?.hidden);
- expect(spec.timeSettings.quickRanges).toEqual(dashboardV1.timepicker?.time_options);
+ expect(spec.timeSettings.quickRanges).toEqual(dashboardV1.timepicker?.quick_ranges);
expect(spec.timeSettings.nowDelay).toBe(dashboardV1.timepicker?.nowDelay);
expect(spec.timeSettings.fiscalYearStartMonth).toBe(dashboardV1.fiscalYearStartMonth);
expect(spec.timeSettings.weekStart).toBe(dashboardV1.weekStart);
@@ -476,7 +487,7 @@ describe('ResponseTransformers', () => {
expect(layout.spec.items[0].spec).toEqual({
element: {
kind: 'ElementReference',
- name: '1',
+ name: 'panel-1',
},
x: 0,
y: 0,
@@ -484,7 +495,7 @@ describe('ResponseTransformers', () => {
height: 8,
repeat: { value: 'var1', direction: 'h', mode: 'variable', maxPerRow: undefined },
});
- expect(spec.elements['1']).toEqual({
+ expect(spec.elements['panel-1']).toEqual({
kind: 'Panel',
spec: {
title: 'Panel Title',
@@ -539,14 +550,14 @@ describe('ResponseTransformers', () => {
expect(layout.spec.items[1].spec).toEqual({
element: {
kind: 'ElementReference',
- name: '2',
+ name: 'panel-2',
},
x: 0,
y: 8,
width: 12,
height: 8,
});
- expect(spec.elements['2']).toEqual({
+ expect(spec.elements['panel-2']).toEqual({
kind: 'LibraryPanel',
spec: {
libraryPanel: {
@@ -569,7 +580,7 @@ describe('ResponseTransformers', () => {
expect(panelInRow).toEqual({
element: {
kind: 'ElementReference',
- name: '4',
+ name: 'panel-4',
},
x: 0,
y: 0,
@@ -587,7 +598,7 @@ describe('ResponseTransformers', () => {
expect(panelInCollapsedRow).toEqual({
element: {
kind: 'ElementReference',
- name: '5',
+ name: 'panel-5',
},
x: 0,
y: 0,
@@ -655,7 +666,18 @@ describe('ResponseTransformers', () => {
autoRefresh: '5m',
autoRefreshIntervals: ['5s', '10s', '30s'],
hideTimepicker: false,
- quickRanges: ['5m', '15m', '1h'],
+ quickRanges: [
+ {
+ display: 'Last 6 hours',
+ from: 'now-6h',
+ to: 'now',
+ },
+ {
+ display: 'Last 7 days',
+ from: 'now-7d',
+ to: 'now',
+ },
+ ],
nowDelay: '1m',
fiscalYearStartMonth: 1,
weekStart: 'monday',
@@ -730,7 +752,6 @@ describe('ResponseTransformers', () => {
expect(dashboard.refresh).toBe(dashboardV2.spec.timeSettings.autoRefresh);
expect(dashboard.timepicker?.refresh_intervals).toEqual(dashboardV2.spec.timeSettings.autoRefreshIntervals);
expect(dashboard.timepicker?.hidden).toBe(dashboardV2.spec.timeSettings.hideTimepicker);
- expect(dashboard.timepicker?.time_options).toEqual(dashboardV2.spec.timeSettings.quickRanges);
expect(dashboard.timepicker?.nowDelay).toBe(dashboardV2.spec.timeSettings.nowDelay);
expect(dashboard.fiscalYearStartMonth).toBe(dashboardV2.spec.timeSettings.fiscalYearStartMonth);
expect(dashboard.weekStart).toBe(dashboardV2.spec.timeSettings.weekStart);
diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts
index b4841435f3a..3d810f3eedf 100644
--- a/public/app/features/dashboard/api/ResponseTransformers.ts
+++ b/public/app/features/dashboard/api/ResponseTransformers.ts
@@ -160,7 +160,7 @@ export function ensureV2Response(
autoRefreshIntervals: dashboard.timepicker?.refresh_intervals || timeSettingsDefaults.autoRefreshIntervals,
fiscalYearStartMonth: dashboard.fiscalYearStartMonth || timeSettingsDefaults.fiscalYearStartMonth,
hideTimepicker: dashboard.timepicker?.hidden || timeSettingsDefaults.hideTimepicker,
- quickRanges: dashboard.timepicker?.time_options || timeSettingsDefaults.quickRanges,
+ quickRanges: dashboard.timepicker?.quick_ranges,
// casting WeekStart here to avoid editing old schema
weekStart: (dashboard.weekStart as WeekStart) || timeSettingsDefaults.weekStart,
nowDelay: dashboard.timepicker?.nowDelay || timeSettingsDefaults.nowDelay,
@@ -252,7 +252,7 @@ export function ensureV1Response(
timepicker: {
refresh_intervals: spec.timeSettings.autoRefreshIntervals,
hidden: spec.timeSettings.hideTimepicker,
- time_options: spec.timeSettings.quickRanges,
+ quick_ranges: spec.timeSettings.quickRanges,
nowDelay: spec.timeSettings.nowDelay,
},
fiscalYearStartMonth: spec.timeSettings.fiscalYearStartMonth,
@@ -383,7 +383,7 @@ function buildElement(p: Panel): [PanelKind | LibraryPanelKind, string] {
},
};
- return [panelKind, p.id!.toString()];
+ return [panelKind, `panel-${p.id}`];
} else {
// PanelKind
@@ -433,7 +433,7 @@ function buildElement(p: Panel): [PanelKind | LibraryPanelKind, string] {
},
};
- return [panelKind, p.id!.toString()];
+ return [panelKind, `panel-${p.id}`];
}
}
diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx
index edd31d452a5..0983707cf94 100644
--- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx
+++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx
@@ -23,7 +23,6 @@ const setupTestContext = (options: Partial) => {
description: 'test dashboard description',
timepicker: {
refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'],
- time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'],
hidden: false,
},
timezone: 'utc',
diff --git a/public/app/features/dashboard/containers/DashboardPageProxy.tsx b/public/app/features/dashboard/containers/DashboardPageProxy.tsx
index 52d91d5d956..1570fbc6ee7 100644
--- a/public/app/features/dashboard/containers/DashboardPageProxy.tsx
+++ b/public/app/features/dashboard/containers/DashboardPageProxy.tsx
@@ -51,6 +51,7 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
route: props.route.routeName as DashboardRoutes,
uid: params.uid ?? '',
type: params.type,
+ slug: params.slug,
});
}, [params.uid, props.route.routeName]);
diff --git a/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx
index 915dd50d69a..b7e9e3ecf2e 100644
--- a/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx
+++ b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx
@@ -245,7 +245,7 @@ describe('PublicDashboardPage', () => {
...dashboardBase,
getModel: () =>
getTestDashboard({
- timepicker: { hidden: false, refresh_intervals: [], time_options: [] },
+ timepicker: { hidden: false, refresh_intervals: [] },
}),
},
});
diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts
index 6ba62a860e5..c41b87c0e4b 100644
--- a/public/app/features/dashboard/state/DashboardMigrator.test.ts
+++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts
@@ -2428,6 +2428,28 @@ describe('when migrating variable refresh to on dashboard load', () => {
});
});
+describe('when migrating time_options in timepicker', () => {
+ let model: DashboardModel;
+
+ it('should remove the property', () => {
+ model = new DashboardModel({
+ timepicker: {
+ //@ts-expect-error
+ time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'],
+ },
+ });
+
+ expect(model.timepicker).not.toHaveProperty('time_options');
+ });
+
+ it('should not throw with empty timepicker', () => {
+ //@ts-expect-error
+ model = new DashboardModel({});
+
+ expect(model.timepicker).not.toHaveProperty('time_options');
+ });
+});
+
function createRow(options: any, panelDescriptions: any[]) {
const PANEL_HEIGHT_STEP = GRID_CELL_HEIGHT + GRID_CELL_VMARGIN;
const { collapse, showTitle, title, repeat, repeatIteration } = options;
diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts
index 02efa8b8ff0..7cfa50f0b7d 100644
--- a/public/app/features/dashboard/state/DashboardMigrator.ts
+++ b/public/app/features/dashboard/state/DashboardMigrator.ts
@@ -81,7 +81,7 @@ type PanelSchemeUpgradeHandler = (panel: PanelModel) => PanelModel;
* kinds/dashboard/dashboard_kind.cue
* Example PR: #87712
*/
-export const DASHBOARD_SCHEMA_VERSION = 40;
+export const DASHBOARD_SCHEMA_VERSION = 41;
export class DashboardMigrator {
dashboard: DashboardModel;
@@ -905,12 +905,20 @@ export class DashboardMigrator {
}
if (oldVersion < 40) {
- // In old ashboards refresh property can be a boolean
+ // In old dashboards refresh property can be a boolean
if (typeof this.dashboard.refresh !== 'string') {
this.dashboard.refresh = '';
}
}
+ if (oldVersion < 41) {
+ // time_options is a legacy property that was not used since grafana version 5
+ // therefore deprecating this property from the schema
+ if ('time_options' in this.dashboard.timepicker) {
+ delete this.dashboard.timepicker.time_options;
+ }
+ }
+
/**
* -==- Add migration here -==-
* Your migration should go below the previous
diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts
index f640cc8cbb6..c54e1743f96 100644
--- a/public/app/features/dashboard/state/DashboardModel.ts
+++ b/public/app/features/dashboard/state/DashboardModel.ts
@@ -286,6 +286,8 @@ export class DashboardModel implements TimeModel {
*
* @internal and experimental
*/
+ // TODO: remove this as it's not being used anymore
+ // Also remove public/app/features/dashboard/utils/panelMerge.ts
updatePanels(panels: IPanelModel[]): PanelMergeInfo {
const info = mergePanels(this.panels, panels ?? []);
if (info.changed) {
diff --git a/public/app/features/dashboard/utils/panelMerge.test.ts b/public/app/features/dashboard/utils/panelMerge.test.ts
index 4bc201aa8ad..51536b2d426 100644
--- a/public/app/features/dashboard/utils/panelMerge.test.ts
+++ b/public/app/features/dashboard/utils/panelMerge.test.ts
@@ -4,7 +4,8 @@ import { FieldColorModeId, ThresholdsMode } from '@grafana/schema/src';
import { DashboardModel } from '../state/DashboardModel';
import { createDashboardModelFixture, createPanelSaveModel } from '../state/__fixtures__/dashboardFixtures';
-describe('Merge dashboard panels', () => {
+// skipping these tests because panelMerge is not used
+describe.skip('Merge dashboard panels', () => {
describe('simple changes', () => {
let dashboard: DashboardModel;
let rawPanels: PanelModel[];
diff --git a/public/app/features/datasources/components/picker/DataSourceCard.tsx b/public/app/features/datasources/components/picker/DataSourceCard.tsx
index 7e14837a32c..84403ae4298 100644
--- a/public/app/features/datasources/components/picker/DataSourceCard.tsx
+++ b/public/app/features/datasources/components/picker/DataSourceCard.tsx
@@ -41,15 +41,14 @@ function getStyles(theme: GrafanaTheme2, builtIn = false) {
return {
card: css({
cursor: 'pointer',
- backgroundColor: theme.colors.background.primary,
- borderBottom: `1px solid ${theme.colors.border.weak}`,
+ backgroundColor: 'transparent',
// Move to list component
marginBottom: 0,
- // set this to 0 to override the default card radius
- // also need to disable our eslint rule
- // eslint-disable-next-line @grafana/no-border-radius-literal
- borderRadius: 0,
padding: theme.spacing(1),
+
+ '&:hover': {
+ backgroundColor: theme.colors.action.hover,
+ },
}),
heading: css({
width: '100%',
@@ -98,7 +97,19 @@ function getStyles(theme: GrafanaTheme2, builtIn = false) {
color: theme.colors.border.weak,
}),
selected: css({
- backgroundColor: theme.colors.background.secondary,
+ background: theme.colors.action.selected,
+
+ '&::before': {
+ backgroundImage: theme.colors.gradients.brandVertical,
+ borderRadius: theme.shape.radius.default,
+ content: '" "',
+ display: 'block',
+ height: '100%',
+ position: 'absolute',
+ transform: 'translateX(-50%)',
+ width: theme.spacing(0.5),
+ left: 0,
+ },
}),
meta: css({
display: 'block',
diff --git a/public/app/features/datasources/components/picker/DataSourceList.tsx b/public/app/features/datasources/components/picker/DataSourceList.tsx
index 11fc0bc90e0..93d8e15f546 100644
--- a/public/app/features/datasources/components/picker/DataSourceList.tsx
+++ b/public/app/features/datasources/components/picker/DataSourceList.tsx
@@ -141,6 +141,7 @@ function getStyles(theme: GrafanaTheme2, selectedItemCssSelector: string) {
container: css({
display: 'flex',
flexDirection: 'column',
+ padding: theme.spacing(0.5),
[`${selectedItemCssSelector}`]: {
backgroundColor: theme.colors.background.secondary,
},
diff --git a/public/app/features/datasources/components/picker/DataSourcePicker.tsx b/public/app/features/datasources/components/picker/DataSourcePicker.tsx
index 47a0366230e..88aebfbafe3 100644
--- a/public/app/features/datasources/components/picker/DataSourcePicker.tsx
+++ b/public/app/features/datasources/components/picker/DataSourcePicker.tsx
@@ -380,8 +380,10 @@ function getStylesPickerContent(theme: GrafanaTheme2) {
container: css({
display: 'flex',
flexDirection: 'column',
- background: theme.colors.background.primary,
+ background: theme.colors.background.elevated,
+ borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z3,
+ overflow: 'hidden',
}),
picker: css({
background: theme.colors.background.secondary,
diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts
index ef9411bea9e..f79968d47ae 100644
--- a/public/app/features/plugins/admin/api.ts
+++ b/public/app/features/plugins/admin/api.ts
@@ -37,6 +37,8 @@ export async function getPluginDetails(id: string): Promise ({
version: v.version,
createdAt: v.createdAt,
+ updatedAt: v.updatedAt,
isCompatible: v.isCompatible,
grafanaDependency: v.grafanaDependency,
angularDetected: v.angularDetected,
diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx
index 4f1bde85393..ad97440a183 100644
--- a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx
+++ b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx
@@ -57,6 +57,7 @@ const plugin: CatalogPlugin = {
],
grafanaDependency: '>=9.0.0',
statusContext: 'stable',
+ changelog: 'Test changelog',
},
angularDetected: false,
isFullyInstalled: true,
@@ -154,4 +155,28 @@ describe('PluginDetailsPage', () => {
render( );
expect(screen.getByRole('tab', { name: 'Data source connections' })).toBeVisible();
});
+
+ it('should not show version and changelog tabs when plugin is core', () => {
+ mockUseGetSingle.mockReturnValue({ ...plugin, isCore: true });
+ render( );
+ expect(screen.queryByRole('tab', { name: 'Version history' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('tab', { name: 'Changelog' })).not.toBeInTheDocument();
+ });
+
+ it('should not show last version in plugin details panel when plugin is core', () => {
+ config.featureToggles.pluginsDetailsRightPanel = true;
+ window.matchMedia = jest.fn().mockImplementation((query) => ({
+ matches: query !== '(max-width: 600px)',
+ media: query,
+ onchange: null,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ }));
+
+ mockUseGetSingle.mockReturnValue({ ...plugin, isCore: true, latestVersion: '1.2.0' });
+
+ render( );
+ expect(screen.queryByText('Latest Version:')).not.toBeInTheDocument();
+ });
});
diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx
index 2169f41e564..3c1e23d36d2 100644
--- a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx
+++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx
@@ -105,7 +105,6 @@ describe('PluginDetailsPanel', () => {
it('should render report abuse section for non-core plugins', () => {
render( );
expect(screen.getByText('Report a concern')).toBeInTheDocument();
- expect(screen.getByText('Contact Grafana Labs')).toBeInTheDocument();
});
it('should not render report abuse section for core plugins', () => {
@@ -117,6 +116,6 @@ describe('PluginDetailsPanel', () => {
it('should respect custom width prop', () => {
render( );
const panel = screen.getByTestId('plugin-details-panel');
- expect(panel).toHaveStyle({ maxWidth: '300px' });
+ expect(panel).toHaveStyle({ width: '300px' });
});
});
diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx
index 95b963097b7..7d7612dc814 100644
--- a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx
+++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx
@@ -1,8 +1,22 @@
import { css } from '@emotion/css';
+import { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
+import { reportInteraction } from '@grafana/runtime';
import { PageInfoItem } from '@grafana/runtime/src/components/PluginPage';
-import { Stack, Text, LinkButton, Box, TextLink, useStyles2 } from '@grafana/ui';
+import {
+ Stack,
+ Text,
+ LinkButton,
+ Box,
+ TextLink,
+ CollapsableSection,
+ Tooltip,
+ Icon,
+ Modal,
+ Button,
+ useStyles2,
+} from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { formatDate } from 'app/core/internationalization/dates';
@@ -16,73 +30,203 @@ type Props = {
export function PluginDetailsPanel(props: Props): React.ReactElement | null {
const { pluginExtentionsInfo, plugin, width = '250px' } = props;
+ const [reportAbuseModalOpen, setReportAbuseModalOpen] = useState(false);
+
+ const normalizeURL = (url: string | undefined) => url?.replace(/\/$/, '');
+
+ const customLinks = plugin.details?.links?.filter((link) => {
+ const customLinksFiltered = ![plugin.url, plugin.details?.licenseUrl, plugin.details?.documentationUrl]
+ .map(normalizeURL)
+ .includes(normalizeURL(link.url));
+ return customLinksFiltered;
+ });
+ const shouldRenderLinks = plugin.url || plugin.details?.licenseUrl || plugin.details?.documentationUrl;
+
const styles = useStyles2(getStyles);
- return (
-
-
-
- {pluginExtentionsInfo.map((infoItem, index) => {
- return (
-
- {infoItem.label + ':'}
- {infoItem.value}
-
- );
- })}
- {plugin.updatedAt && (
-
-
- Last updated:
- {' '}
- {formatDate(new Date(plugin.updatedAt), { day: 'numeric', month: 'short', year: 'numeric' })}
-
- )}
- {plugin?.details?.lastCommitDate && (
-
-
- Last commit date:
- {' '}
-
- {formatDate(new Date(plugin.details.lastCommitDate), {
- day: 'numeric',
- month: 'short',
- year: 'numeric',
- })}
-
-
- )}
-
-
+ const onClickReportConcern = (pluginId: string) => {
+ setReportAbuseModalOpen(true);
+ reportInteraction('plugin_detail_report_concern', {
+ plugin_id: pluginId,
+ });
+ };
- {plugin?.details?.links && plugin.details?.links?.length > 0 && (
+ return (
+ <>
+
-
- Links
-
- {plugin.details.links.map((link, index) => (
-
- {link.name}
-
- ))}
+ {pluginExtentionsInfo.map((infoItem, index) => {
+ return (
+
+ {infoItem.label + ':'}
+ {infoItem.value}
+
+ );
+ })}
+ {plugin.updatedAt && (
+
+
+ Last updated:
+ {' '}
+
+ {formatDate(new Date(plugin.updatedAt), { day: 'numeric', month: 'short', year: 'numeric' })}
+
+
+ )}
+ {plugin?.details?.lastCommitDate && (
+
+
+ Last commit date:
+ {' '}
+
+ {formatDate(new Date(plugin.details.lastCommitDate), {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ })}
+
+
+ )}
- )}
-
- {!plugin?.isCore && (
-
-
-
- Report a concern
+ {shouldRenderLinks && (
+ <>
+
+
+ {plugin.url && (
+
+ Repository
+
+ )}
+ {plugin.raiseAnIssueUrl && (
+
+ Raise an issue
+
+ )}
+ {plugin.details?.licenseUrl && (
+
+ License
+
+ )}
+ {plugin.details?.documentationUrl && (
+
+ Documentation
+
+ )}
+
+
+ >
+ )}
+ {customLinks && customLinks?.length > 0 && (
+
+
+
+ Custom links
+
+
+ These links are provided by the plugin developer to offer additional, developer-specific
+ resources and information
+
+ }
+ placement="right-end"
+ >
+
+
+
+ }
+ >
+
+ {customLinks.map((link, index) => (
+
+ {link.name}
+
+ ))}
+
+
+
+ )}
+ {!plugin?.isCore && (
+
+
+
+ Report a concern
+
+
+ Report issues related to malicious or harmful plugins directly to Grafana Labs.
+
+ }
+ placement="right-end"
+ >
+
+
+
+ }
+ >
+
+ onClickReportConcern(plugin.id)}>
+ Contact Grafana Labs
+
+
+
+
+ )}
+
+ {reportAbuseModalOpen && (
+ Report a plugin concern}
+ isOpen
+ onDismiss={() => setReportAbuseModalOpen(false)}
+ >
+
+
+
+ This feature is for reporting malicious or harmful behaviour within plugins. For plugin concerns, email
+ us at:{' '}
+
+ integrations@grafana.com
+
+
+
+ Note: For general plugin issues like bugs or feature requests, please contact the plugin author using
+ the provided links.{' '}
+
-
- Contact Grafana Labs
-
-
+
+ setReportAbuseModalOpen(false)}>
+ Cancel
+
+ navigator.clipboard.writeText('integrations@grafana.com')}>
+ Copy email address
+
+
+
)}
-
+ >
);
}
diff --git a/public/app/features/plugins/admin/components/VersionList.tsx b/public/app/features/plugins/admin/components/VersionList.tsx
index aaf98fd183b..2e79b0d43c1 100644
--- a/public/app/features/plugins/admin/components/VersionList.tsx
+++ b/public/app/features/plugins/admin/components/VersionList.tsx
@@ -96,7 +96,7 @@ export const VersionList = ({ pluginId, versions = [], installedVersion, disable
{/* Last updated */}
- {dateTimeFormatTimeAgo(version.createdAt)}
+ {dateTimeFormatTimeAgo(version.updatedAt || version.createdAt)}
{/* Dependency */}
{version.grafanaDependency || 'N/A'}
diff --git a/public/app/features/plugins/admin/helpers.test.ts b/public/app/features/plugins/admin/helpers.test.ts
index b47c5bf774b..b1b109f2ca5 100644
--- a/public/app/features/plugins/admin/helpers.test.ts
+++ b/public/app/features/plugins/admin/helpers.test.ts
@@ -217,6 +217,7 @@ describe('Plugins/Helpers', () => {
updatedAt: '2021-05-18T14:53:01.000Z',
isFullyInstalled: false,
angularDetected: false,
+ url: 'https://github.com/alexanderzobnin/grafana-zabbix',
});
});
@@ -354,6 +355,7 @@ describe('Plugins/Helpers', () => {
installedVersion: '4.2.2',
isFullyInstalled: true,
angularDetected: false,
+ url: 'https://github.com/alexanderzobnin/grafana-zabbix',
});
});
diff --git a/public/app/features/plugins/admin/helpers.ts b/public/app/features/plugins/admin/helpers.ts
index 172a32e088f..2f7814cc729 100644
--- a/public/app/features/plugins/admin/helpers.ts
+++ b/public/app/features/plugins/admin/helpers.ts
@@ -121,6 +121,8 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C
signatureType,
versionSignatureType,
versionSignedByOrgName,
+ url,
+ raiseAnIssueUrl,
} = plugin;
const isDisabled = !!error || isDisabledSecretsPlugin(typeCode);
@@ -158,6 +160,8 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C
angularDetected,
isFullyInstalled: isDisabled,
latestVersion: plugin.version,
+ url,
+ raiseAnIssueUrl,
};
}
@@ -174,6 +178,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
hasUpdate,
accessControl,
angularDetected,
+ raiseAnIssueUrl,
} = plugin;
const isDisabled = !!error || isDisabledSecretsPlugin(type);
@@ -208,6 +213,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
isFullyInstalled: true,
iam: plugin.iam,
latestVersion: plugin.latestVersion,
+ raiseAnIssueUrl,
};
}
@@ -271,6 +277,8 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, e
isFullyInstalled: Boolean(local) || isDisabled,
iam: local?.iam,
latestVersion: local?.latestVersion || remote?.version || '',
+ url: remote?.url || '',
+ raiseAnIssueUrl: remote?.raiseAnIssueUrl || local?.raiseAnIssueUrl,
};
}
diff --git a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx
index e9c50269d15..d9f7c2e1375 100644
--- a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx
+++ b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx
@@ -42,7 +42,8 @@ export const usePluginDetailsTabs = (
const navModelChildren = useMemo(() => {
const canConfigurePlugins = plugin && contextSrv.hasPermissionInMetadata(AccessControlAction.PluginsWrite, plugin);
const navModelChildren: NavModelItem[] = [];
- if (isPublished) {
+ // currently the versions available of core plugins are not consistent
+ if (isPublished && !plugin?.isCore) {
navModelChildren.push({
text: PluginTabLabels.VERSIONS,
id: PluginTabIds.VERSIONS,
@@ -51,7 +52,8 @@ export const usePluginDetailsTabs = (
active: PluginTabIds.VERSIONS === currentPageId,
});
}
- if (isPublished && plugin?.details?.changelog) {
+ // currently there is not changelog available for core plugins
+ if (isPublished && plugin?.details?.changelog && !plugin.isCore) {
navModelChildren.push({
text: PluginTabLabels.CHANGELOG,
id: PluginTabIds.CHANGELOG,
diff --git a/public/app/features/plugins/admin/hooks/usePluginInfo.tsx b/public/app/features/plugins/admin/hooks/usePluginInfo.tsx
index 1bb1334e1ef..2c9124f1860 100644
--- a/public/app/features/plugins/admin/hooks/usePluginInfo.tsx
+++ b/public/app/features/plugins/admin/hooks/usePluginInfo.tsx
@@ -53,7 +53,10 @@ export const usePluginInfo = (plugin?: CatalogPlugin): PageInfoItem[] => {
latestVersionValue = latestVersion;
}
- addInfo('latestVersion', latestVersionValue);
+ // latest versions of core plugins are not consistent
+ if (!plugin.isCore) {
+ addInfo('latestVersion', latestVersionValue);
+ }
}
if (Boolean(plugin.orgName)) {
diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts
index 10e2636b2ef..f3d5783ae3c 100644
--- a/public/app/features/plugins/admin/types.ts
+++ b/public/app/features/plugins/admin/types.ts
@@ -64,6 +64,8 @@ export interface CatalogPlugin extends WithAccessControlMetadata {
isUpdatingFromInstance?: boolean;
iam?: IdentityAccessManagement;
isProvisioned?: boolean;
+ url?: string;
+ raiseAnIssueUrl?: string;
}
export interface CatalogPluginDetails {
@@ -79,6 +81,8 @@ export interface CatalogPluginDetails {
iam?: IdentityAccessManagement;
changelog?: string;
lastCommitDate?: string;
+ licenseUrl?: string;
+ documentationUrl?: string;
signatureType?: PluginSignatureType;
signature?: PluginSignatureStatus;
}
@@ -143,6 +147,9 @@ export type RemotePlugin = {
versionStatus: string;
angularDetected?: boolean;
lastCommitDate?: string;
+ licenseUrl?: string;
+ documentationUrl?: string;
+ raiseAnIssueUrl?: string;
};
// The available status codes on GCOM are available here:
@@ -190,6 +197,7 @@ export type LocalPlugin = WithAccessControlMetadata & {
dependencies: PluginDependencies;
angularDetected: boolean;
iam?: IdentityAccessManagement;
+ raiseAnIssueUrl?: string;
};
interface IdentityAccessManagement {
@@ -216,6 +224,7 @@ export interface Build {
export interface Version {
version: string;
createdAt: string;
+ updatedAt?: string;
isCompatible: boolean;
grafanaDependency: string | null;
angularDetected?: boolean;
diff --git a/public/app/features/plugins/components/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx
index 84736aed8de..c9e5ec616be 100644
--- a/public/app/features/plugins/components/AppRootPage.test.tsx
+++ b/public/app/features/plugins/components/AppRootPage.test.tsx
@@ -12,6 +12,7 @@ import { Echo } from 'app/core/services/echo/Echo';
import { ExtensionRegistriesProvider } from '../extensions/ExtensionRegistriesContext';
import { AddedComponentsRegistry } from '../extensions/registry/AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from '../extensions/registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from '../extensions/registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from '../extensions/registry/ExposedComponentsRegistry';
import { getPluginSettings } from '../pluginSettings';
@@ -93,6 +94,7 @@ function renderUnderRouter(page = '') {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
+ addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
const pagePath = page ? `/${page}` : '';
const route = {
diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx
index f0d6bd5329e..95a280027df 100644
--- a/public/app/features/plugins/components/AppRootPage.tsx
+++ b/public/app/features/plugins/components/AppRootPage.tsx
@@ -29,6 +29,7 @@ import {
useAddedLinksRegistry,
useAddedComponentsRegistry,
useExposedComponentsRegistry,
+ useAddedFunctionsRegistry,
} from '../extensions/ExtensionRegistriesContext';
import { getPluginSettings } from '../pluginSettings';
import { importAppPlugin } from '../plugin_loader';
@@ -60,6 +61,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
const addedLinksRegistry = useAddedLinksRegistry();
const addedComponentsRegistry = useAddedComponentsRegistry();
const exposedComponentsRegistry = useExposedComponentsRegistry();
+ const addedFunctionsRegistry = useAddedFunctionsRegistry();
const location = useLocation();
const [state, dispatch] = useReducer(stateSlice.reducer, initialState);
const currentUrl = config.appSubUrl + location.pathname + location.search;
@@ -104,6 +106,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
addedLinksRegistry: addedLinksRegistry.readOnly(),
addedComponentsRegistry: addedComponentsRegistry.readOnly(),
exposedComponentsRegistry: exposedComponentsRegistry.readOnly(),
+ addedFunctionsRegistry: addedFunctionsRegistry.readOnly(),
}}
>
(undefined);
export const AddedComponentsRegistryContext = createContext(undefined);
+export const AddedFunctionsRegistryContext = createContext(undefined);
export const ExposedComponentsRegistryContext = createContext(undefined);
export function useAddedLinksRegistry(): AddedLinksRegistry {
@@ -31,6 +33,14 @@ export function useAddedComponentsRegistry(): AddedComponentsRegistry {
return context;
}
+export function useAddedFunctionsRegistry(): AddedFunctionsRegistry {
+ const context = useContext(AddedFunctionsRegistryContext);
+ if (!context) {
+ throw new Error('No `AddedFunctionsRegistry` found.');
+ }
+ return context;
+}
+
export function useExposedComponentsRegistry(): ExposedComponentsRegistry {
const context = useContext(ExposedComponentsRegistryContext);
if (!context) {
@@ -46,9 +56,11 @@ export const ExtensionRegistriesProvider = ({
return (
-
- {children}
-
+
+
+ {children}
+
+
);
diff --git a/public/app/features/plugins/extensions/errors.ts b/public/app/features/plugins/extensions/errors.ts
index 39c65df0f8e..cb9d22e7b46 100644
--- a/public/app/features/plugins/extensions/errors.ts
+++ b/public/app/features/plugins/extensions/errors.ts
@@ -8,6 +8,8 @@ export const TITLE_MISSING = 'Title is missing.';
export const DESCRIPTION_MISSING = 'Description is missing.';
+export const INVALID_EXTENSION_FUNCTION = 'The "fn" argument is invalid, it should be a function.';
+
export const INVALID_CONFIGURE_FUNCTION = 'The "configure" function is invalid. It should be a function.';
export const INVALID_PATH_OR_ON_CLICK = 'Either "path" or "onClick" is required.';
@@ -33,6 +35,9 @@ export const TITLE_NOT_MATCHING_META_INFO = 'The "title" doesn\'t match the titl
export const ADDED_LINK_META_INFO_MISSING =
'The extension was not recorded in the plugin.json. Added link extensions must be listed in the section "extensions.addedLinks[]". Currently, this is only required in development but will be enforced also in production builds in the future.';
+export const ADDED_FUNCTION_META_INFO_MISSING =
+ 'The extension was not recorded in the plugin.json. Added function extensions must be listed in the section "extensions.addedFunction[]". Currently, this is only required in development but will be enforced also in production builds in the future.';
+
export const DESCRIPTION_NOT_MATCHING_META_INFO =
'The "description" doesn\'t match the description recorded in plugin.json.';
diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts
index a47292c8497..30f5865b259 100644
--- a/public/app/features/plugins/extensions/getPluginExtensions.ts
+++ b/public/app/features/plugins/extensions/getPluginExtensions.ts
@@ -1,8 +1,8 @@
import { isString } from 'lodash';
import {
- type PluginExtension,
PluginExtensionTypes,
+ type PluginExtension,
type PluginExtensionLink,
type PluginExtensionComponent,
} from '@grafana/data';
diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts
index e82c48dcee0..5a18e222431 100644
--- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts
+++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts
@@ -52,6 +52,7 @@ describe('AddedComponentsRegistry', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts
new file mode 100644
index 00000000000..ae0addc52c7
--- /dev/null
+++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts
@@ -0,0 +1,677 @@
+import { firstValueFrom } from 'rxjs';
+
+import { PluginLoadingStrategy } from '@grafana/data';
+import { config } from '@grafana/runtime';
+
+import { log } from '../logs/log';
+import { resetLogMock } from '../logs/testUtils';
+import { isGrafanaDevMode } from '../utils';
+
+import { AddedFunctionsRegistry } from './AddedFunctionsRegistry';
+import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry';
+
+jest.mock('../utils', () => ({
+ ...jest.requireActual('../utils'),
+
+ // Manually set the dev mode to false
+ // (to make sure that by default we are testing a production scneario)
+ isGrafanaDevMode: jest.fn().mockReturnValue(false),
+}));
+
+jest.mock('../logs/log', () => {
+ const { createLogMock } = jest.requireActual('../logs/testUtils');
+ const original = jest.requireActual('../logs/log');
+
+ return {
+ ...original,
+ log: createLogMock(),
+ };
+});
+
+describe('addedFunctionsRegistry', () => {
+ const originalApps = config.apps;
+ const pluginId = 'grafana-basic-app';
+ const appPluginConfig = {
+ id: pluginId,
+ path: '',
+ version: '',
+ preload: false,
+ angular: {
+ detected: false,
+ hideDeprecation: false,
+ },
+ loadingStrategy: PluginLoadingStrategy.fetch,
+ dependencies: {
+ grafanaVersion: '8.0.0',
+ plugins: [],
+ extensions: {
+ exposedComponents: [],
+ },
+ },
+ extensions: {
+ addedFunctions: [],
+ addedLinks: [],
+ addedComponents: [],
+ exposedComponents: [],
+ extensionPoints: [],
+ },
+ };
+
+ beforeEach(() => {
+ resetLogMock(log);
+ jest.mocked(isGrafanaDevMode).mockReturnValue(false);
+ config.apps = {
+ [pluginId]: appPluginConfig,
+ };
+ });
+
+ afterEach(() => {
+ config.apps = originalApps;
+ });
+
+ it('should return empty registry when no extensions registered', async () => {
+ const addedFunctionsRegistry = new AddedFunctionsRegistry();
+ const observable = addedFunctionsRegistry.asObservable();
+ const registry = await firstValueFrom(observable);
+ expect(registry).toEqual({});
+ });
+
+ it('should be possible to register function extensions in the registry', async () => {
+ const addedFunctionsRegistry = new AddedFunctionsRegistry();
+
+ addedFunctionsRegistry.register({
+ pluginId,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn(),
+ },
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'plugins/myorg-basic-app/start',
+ fn: jest.fn(),
+ },
+ ],
+ });
+
+ const registry = await addedFunctionsRegistry.getState();
+
+ expect(registry).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ 'plugins/myorg-basic-app/start': [
+ {
+ pluginId: pluginId,
+ title: 'Function 2',
+ description: 'Function 2 description',
+ extensionPointId: 'plugins/myorg-basic-app/start',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+ it('should be possible to asynchronously register function extensions for the same placement (different plugins)', async () => {
+ const pluginId1 = 'grafana-basic-app';
+ const pluginId2 = 'grafana-basic-app2';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+
+ // Register extensions for the first plugin
+ reactiveRegistry.register({
+ pluginId: pluginId1,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry1 = await reactiveRegistry.getState();
+
+ expect(registry1).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId1,
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+
+ // Register extensions for the second plugin to a different placement
+ reactiveRegistry.register({
+ pluginId: pluginId2,
+ configs: [
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry2 = await reactiveRegistry.getState();
+
+ expect(registry2).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId1,
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ {
+ pluginId: pluginId2,
+ title: 'Function 2',
+ description: 'Function 2 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should be possible to asynchronously register function extensions for a different placement (different plugin)', async () => {
+ const pluginId1 = 'grafana-basic-app';
+ const pluginId2 = 'grafana-basic-app2';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+
+ // Register extensions for the first plugin
+ reactiveRegistry.register({
+ pluginId: pluginId1,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry1 = await reactiveRegistry.getState();
+
+ expect(registry1).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId1,
+
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+
+ // Register extensions for the second plugin to a different placement
+ reactiveRegistry.register({
+ pluginId: pluginId2,
+ configs: [
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'plugins/myorg-basic-app/start',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry2 = await reactiveRegistry.getState();
+
+ expect(registry2).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId1,
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ 'plugins/myorg-basic-app/start': [
+ {
+ pluginId: pluginId2,
+ title: 'Function 2',
+ description: 'Function 2 description',
+ extensionPointId: 'plugins/myorg-basic-app/start',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should be possible to asynchronously register function extensions for the same placement (same plugin)', async () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+
+ // Register extensions for the first extension point
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ // Register extensions to a different extension point
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry2 = await reactiveRegistry.getState();
+
+ expect(registry2).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ {
+ pluginId: pluginId,
+
+ title: 'Function 2',
+ description: 'Function 2 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should be possible to asynchronously register function extensions for a different placement (same plugin)', async () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+
+ // Register extensions for the first extension point
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ // Register extensions to a different extension point
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'plugins/myorg-basic-app/start',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry2 = await reactiveRegistry.getState();
+
+ expect(registry2).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ 'plugins/myorg-basic-app/start': [
+ {
+ pluginId: pluginId,
+
+ title: 'Function 2',
+ description: 'Function 2 description',
+ extensionPointId: 'plugins/myorg-basic-app/start',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should notify subscribers when the registry changes', async () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+ const observable = reactiveRegistry.asObservable();
+ const subscribeCallback = jest.fn();
+
+ observable.subscribe(subscribeCallback);
+
+ // Register extensions for the first plugin
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ expect(subscribeCallback).toHaveBeenCalledTimes(2);
+
+ // Register extensions for the first plugin
+ reactiveRegistry.register({
+ pluginId: 'another-plugin',
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ expect(subscribeCallback).toHaveBeenCalledTimes(3);
+
+ const registry = subscribeCallback.mock.calls[2][0];
+
+ expect(registry).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ {
+ pluginId: 'another-plugin',
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should give the last version of the registry for new subscribers', async () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+ const observable = reactiveRegistry.asObservable();
+ const subscribeCallback = jest.fn();
+
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ observable.subscribe(subscribeCallback);
+ expect(subscribeCallback).toHaveBeenCalledTimes(1);
+
+ const registry = subscribeCallback.mock.calls[0][0];
+
+ expect(registry).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+ title: 'Function 1',
+ description: 'Function 1 description',
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ fn: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should not register a function extension if it has an invalid fn function', () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+ const observable = reactiveRegistry.asObservable();
+ const subscribeCallback = jest.fn();
+
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ //@ts-ignore
+ fn: '...',
+ },
+ ],
+ });
+
+ expect(log.error).toHaveBeenCalled();
+
+ observable.subscribe(subscribeCallback);
+ expect(subscribeCallback).toHaveBeenCalledTimes(1);
+
+ const registry = subscribeCallback.mock.calls[0][0];
+ expect(registry).toEqual({});
+ });
+
+ it('should not register a function extension if it has invalid properties (empty title)', () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedFunctionsRegistry();
+ const observable = reactiveRegistry.asObservable();
+ const subscribeCallback = jest.fn();
+
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: '',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ expect(log.error).toHaveBeenCalled();
+
+ observable.subscribe(subscribeCallback);
+ expect(subscribeCallback).toHaveBeenCalledTimes(1);
+
+ const registry = subscribeCallback.mock.calls[0][0];
+ expect(registry).toEqual({});
+ });
+
+ it('should not be possible to register a function on a read-only registry', async () => {
+ const pluginId = 'grafana-basic-app';
+ const registry = new AddedFunctionsRegistry();
+ const readOnlyRegistry = registry.readOnly();
+
+ expect(() => {
+ readOnlyRegistry.register({
+ pluginId,
+ configs: [
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'plugins/myorg-basic-app/start',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+ }).toThrow(MSG_CANNOT_REGISTER_READ_ONLY);
+
+ const currentState = await readOnlyRegistry.getState();
+ expect(Object.keys(currentState)).toHaveLength(0);
+ });
+
+ it('should pass down fresh registrations to the read-only version of the registry', async () => {
+ const pluginId = 'grafana-basic-app';
+ const registry = new AddedFunctionsRegistry();
+ const readOnlyRegistry = registry.readOnly();
+ const subscribeCallback = jest.fn();
+ let readOnlyState;
+
+ // Should have no extensions registered in the beginning
+ readOnlyState = await readOnlyRegistry.getState();
+ expect(Object.keys(readOnlyState)).toHaveLength(0);
+
+ readOnlyRegistry.asObservable().subscribe(subscribeCallback);
+
+ // Register an extension to the original (writable) registry
+ registry.register({
+ pluginId,
+ configs: [
+ {
+ title: 'Function 2',
+ description: 'Function 2 description',
+ targets: 'plugins/myorg-basic-app/start',
+ fn: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ // The read-only registry should have received the new extension
+ readOnlyState = await readOnlyRegistry.getState();
+ expect(Object.keys(readOnlyState)).toHaveLength(1);
+
+ expect(subscribeCallback).toHaveBeenCalledTimes(2);
+ expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['plugins/myorg-basic-app/start']);
+ });
+
+ it('should not register a function added by a plugin in dev-mode if the meta-info is missing from the plugin.json', async () => {
+ // Enabling dev mode
+ jest.mocked(isGrafanaDevMode).mockReturnValue(true);
+
+ const registry = new AddedFunctionsRegistry();
+ const fnConfig = {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ };
+
+ // Make sure that the meta-info is empty
+ config.apps[pluginId].extensions.addedFunctions = [];
+
+ registry.register({
+ pluginId,
+ configs: [fnConfig],
+ });
+
+ const currentState = await registry.getState();
+
+ expect(Object.keys(currentState)).toHaveLength(0);
+ expect(log.error).toHaveBeenCalled();
+ });
+
+ it('should register a function added by core Grafana in dev-mode even if the meta-info is missing', async () => {
+ // Enabling dev mode
+ jest.mocked(isGrafanaDevMode).mockReturnValue(true);
+
+ const registry = new AddedFunctionsRegistry();
+ const fnConfig = {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ };
+
+ registry.register({
+ pluginId: 'grafana',
+ configs: [fnConfig],
+ });
+
+ const currentState = await registry.getState();
+
+ expect(Object.keys(currentState)).toHaveLength(1);
+ expect(log.error).not.toHaveBeenCalled();
+ });
+
+ it('should register a function added by a plugin in production mode even if the meta-info is missing', async () => {
+ // Production mode
+ jest.mocked(isGrafanaDevMode).mockReturnValue(false);
+
+ const registry = new AddedFunctionsRegistry();
+ const fnConfig = {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: 'grafana/dashboard/panel/menu',
+ fn: jest.fn().mockReturnValue({}),
+ };
+
+ // Make sure that the meta-info is empty
+ config.apps[pluginId].extensions.addedFunctions = [];
+
+ registry.register({
+ pluginId,
+ configs: [fnConfig],
+ });
+
+ const currentState = await registry.getState();
+
+ expect(Object.keys(currentState)).toHaveLength(1);
+ expect(log.error).not.toHaveBeenCalled();
+ });
+
+ it('should register a function added by a plugin in dev-mode if the meta-info is present', async () => {
+ // Enabling dev mode
+ jest.mocked(isGrafanaDevMode).mockReturnValue(true);
+
+ const registry = new AddedFunctionsRegistry();
+ const fnConfig = {
+ title: 'Function 1',
+ description: 'Function 1 description',
+ targets: ['grafana/dashboard/panel/menu'],
+ fn: jest.fn().mockReturnValue({}),
+ };
+
+ // Make sure that the meta-info is empty
+ config.apps[pluginId].extensions.addedFunctions = [fnConfig];
+
+ registry.register({
+ pluginId,
+ configs: [fnConfig],
+ });
+
+ const currentState = await registry.getState();
+
+ expect(Object.keys(currentState)).toHaveLength(1);
+ expect(log.error).not.toHaveBeenCalled();
+ });
+});
diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts
new file mode 100644
index 00000000000..d23fe5e78b2
--- /dev/null
+++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts
@@ -0,0 +1,87 @@
+import { isFunction } from 'lodash';
+import { ReplaySubject } from 'rxjs';
+
+import { PluginExtensionAddedFunctionConfig } from '@grafana/data';
+
+import * as errors from '../errors';
+import { isGrafanaDevMode } from '../utils';
+import { isAddedFunctionMetaInfoMissing } from '../validators';
+
+import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
+
+const logPrefix = 'Could not register function extension. Reason:';
+
+export type AddedFunctionsRegistryItem = {
+ pluginId: string;
+ title: string;
+ fn: unknown;
+ description?: string;
+};
+
+export class AddedFunctionsRegistry extends Registry {
+ constructor(
+ options: {
+ registrySubject?: ReplaySubject>;
+ initialState?: RegistryType;
+ } = {}
+ ) {
+ super(options);
+ }
+
+ mapToRegistry(
+ registry: RegistryType,
+ item: PluginExtensionConfigs
+ ): RegistryType {
+ const { pluginId, configs } = item;
+ for (const config of configs) {
+ const configLog = this.logger.child({
+ title: config.title,
+ pluginId,
+ });
+
+ if (!config.title) {
+ configLog.error(`${logPrefix} ${errors.TITLE_MISSING}`);
+ continue;
+ }
+
+ if (!isFunction(config.fn)) {
+ configLog.error(`${logPrefix} ${errors.INVALID_EXTENSION_FUNCTION}`);
+ continue;
+ }
+
+ if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedFunctionMetaInfoMissing(pluginId, config, configLog)) {
+ continue;
+ }
+
+ const extensionPointIds = Array.isArray(config.targets) ? config.targets : [config.targets];
+ for (const extensionPointId of extensionPointIds) {
+ const pointIdLog = configLog.child({ extensionPointId });
+
+ const result = {
+ pluginId,
+ fn: config.fn,
+ description: config.description,
+ title: config.title,
+ extensionPointId,
+ };
+
+ pointIdLog.debug('Added function extension successfully registered');
+
+ if (!(extensionPointId in registry)) {
+ registry[extensionPointId] = [result];
+ } else {
+ registry[extensionPointId].push(result);
+ }
+ }
+ }
+
+ return registry;
+ }
+
+ // Returns a read-only version of the registry.
+ readOnly() {
+ return new AddedFunctionsRegistry({
+ registrySubject: this.registrySubject,
+ });
+ }
+}
diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts
index 4d5ab5c084f..d3586240276 100644
--- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts
+++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts
@@ -51,6 +51,7 @@ describe('AddedLinksRegistry', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
diff --git a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts
index 0a7036894d1..863c89f7b40 100644
--- a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts
+++ b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts
@@ -52,6 +52,7 @@ describe('ExposedComponentsRegistry', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts
index 6c2fd1e6a5f..91b7badc4eb 100644
--- a/public/app/features/plugins/extensions/registry/setup.ts
+++ b/public/app/features/plugins/extensions/registry/setup.ts
@@ -1,6 +1,7 @@
import { getCoreExtensionConfigurations } from '../getCoreExtensionConfigurations';
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from './AddedFunctionsRegistry';
import { AddedLinksRegistry } from './AddedLinksRegistry';
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './types';
@@ -8,10 +9,12 @@ import { PluginExtensionRegistries } from './types';
export const addedComponentsRegistry = new AddedComponentsRegistry();
export const exposedComponentsRegistry = new ExposedComponentsRegistry();
export const addedLinksRegistry = new AddedLinksRegistry();
+export const addedFunctionsRegistry = new AddedFunctionsRegistry();
export const pluginExtensionRegistries: PluginExtensionRegistries = {
addedComponentsRegistry,
exposedComponentsRegistry,
addedLinksRegistry,
+ addedFunctionsRegistry,
};
// Registering core extensions
diff --git a/public/app/features/plugins/extensions/registry/types.ts b/public/app/features/plugins/extensions/registry/types.ts
index 115e859b7d9..1927bf31b75 100644
--- a/public/app/features/plugins/extensions/registry/types.ts
+++ b/public/app/features/plugins/extensions/registry/types.ts
@@ -1,9 +1,11 @@
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from './AddedFunctionsRegistry';
import { AddedLinksRegistry } from './AddedLinksRegistry';
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
export type PluginExtensionRegistries = {
addedComponentsRegistry: AddedComponentsRegistry;
exposedComponentsRegistry: ExposedComponentsRegistry;
+ addedFunctionsRegistry: AddedFunctionsRegistry;
addedLinksRegistry: AddedLinksRegistry;
};
diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx
index 6385c022027..b2a3d3d3435 100644
--- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx
@@ -7,6 +7,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
@@ -78,6 +79,7 @@ describe('usePluginComponent()', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
// This is necessary, so we can register exposed components to the registry during the tests
// (Otherwise the registry would reject it in the imitated production mode)
exposedComponents: [exposedComponentConfig],
@@ -90,6 +92,7 @@ describe('usePluginComponent()', () => {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
+ addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
@@ -122,6 +125,7 @@ describe('usePluginComponent()', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',
diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx
index 75adda33f75..fcb729fdd64 100644
--- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx
@@ -6,6 +6,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
@@ -60,6 +61,7 @@ describe('usePluginComponents()', () => {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
+ addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
jest.mocked(wrapWithPluginContext).mockClear();
@@ -89,6 +91,7 @@ describe('usePluginComponents()', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',
diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx
index 739906dd815..479e5cd4c6c 100644
--- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx
@@ -1,6 +1,7 @@
import { act, renderHook } from '@testing-library/react';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
@@ -19,6 +20,7 @@ describe('usePluginExtensions()', () => {
addedComponentsRegistry: new AddedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
+ addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
});
diff --git a/public/app/features/plugins/extensions/usePluginFunctions.tsx b/public/app/features/plugins/extensions/usePluginFunctions.tsx
new file mode 100644
index 00000000000..68acee76221
--- /dev/null
+++ b/public/app/features/plugins/extensions/usePluginFunctions.tsx
@@ -0,0 +1,82 @@
+import { useMemo } from 'react';
+import { useObservable } from 'react-use';
+
+import { usePluginContext, PluginExtensionFunction, PluginExtensionTypes } from '@grafana/data';
+import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from '@grafana/runtime';
+
+import { useAddedFunctionsRegistry } from './ExtensionRegistriesContext';
+import * as errors from './errors';
+import { log } from './logs/log';
+import { useLoadAppPlugins } from './useLoadAppPlugins';
+import { generateExtensionId, getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
+import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
+
+// Returns an array of component extensions for the given extension point
+export function usePluginFunctions({
+ limitPerPlugin,
+ extensionPointId,
+}: UsePluginFunctionsOptions): UsePluginFunctionsResult {
+ const registry = useAddedFunctionsRegistry();
+ const registryState = useObservable(registry.asObservable());
+ const pluginContext = usePluginContext();
+ const deps = getExtensionPointPluginDependencies(extensionPointId);
+ const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps);
+
+ return useMemo(() => {
+ // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
+ const enableRestrictions = isGrafanaDevMode() && pluginContext;
+ const results: Array> = [];
+ const extensionsByPlugin: Record = {};
+ const pluginId = pluginContext?.meta.id ?? '';
+ const pointLog = log.child({
+ pluginId,
+ extensionPointId,
+ });
+ if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) {
+ pointLog.error(errors.INVALID_EXTENSION_POINT_ID);
+ }
+
+ if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
+ pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
+ return {
+ isLoading: false,
+ functions: [],
+ };
+ }
+
+ if (isLoadingAppPlugins) {
+ return {
+ isLoading: true,
+ functions: [],
+ };
+ }
+
+ for (const registryItem of registryState?.[extensionPointId] ?? []) {
+ const { pluginId } = registryItem;
+
+ // Only limit if the `limitPerPlugin` is set
+ if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
+ continue;
+ }
+
+ if (extensionsByPlugin[pluginId] === undefined) {
+ extensionsByPlugin[pluginId] = 0;
+ }
+
+ results.push({
+ id: generateExtensionId(pluginId, extensionPointId, registryItem.title),
+ type: PluginExtensionTypes.function,
+ title: registryItem.title,
+ description: registryItem.description ?? '',
+ pluginId: pluginId,
+ fn: registryItem.fn as Signature,
+ });
+ extensionsByPlugin[pluginId] += 1;
+ }
+
+ return {
+ isLoading: false,
+ functions: results,
+ };
+ }, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]);
+}
diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx
index 9186ca0ddf6..f9fb623f42a 100644
--- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx
@@ -6,6 +6,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
@@ -57,6 +58,7 @@ describe('usePluginLinks()', () => {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
+ addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
resetLogMock(log);
@@ -85,6 +87,7 @@ describe('usePluginLinks()', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',
diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx
index 32ad6215875..0e2d98f44b3 100644
--- a/public/app/features/plugins/extensions/utils.test.tsx
+++ b/public/app/features/plugins/extensions/utils.test.tsx
@@ -475,6 +475,7 @@ describe('Plugin Extensions / Utils', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
@@ -553,6 +554,7 @@ describe('Plugin Extensions / Utils', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
@@ -584,6 +586,7 @@ describe('Plugin Extensions / Utils', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
},
'myorg-third-app': {
@@ -623,6 +626,7 @@ describe('Plugin Extensions / Utils', () => {
],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
},
};
@@ -679,6 +683,7 @@ describe('Plugin Extensions / Utils', () => {
],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
@@ -705,6 +710,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
@@ -726,6 +732,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
+ addedFunctions: [],
},
},
'myorg-sixth-app': {
@@ -763,6 +770,7 @@ describe('Plugin Extensions / Utils', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
};
@@ -791,6 +799,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
+ addedFunctions: [],
},
},
'myorg-third-app': {
@@ -825,6 +834,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
@@ -850,6 +860,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
@@ -871,6 +882,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
+ addedFunctions: [],
},
},
};
@@ -902,6 +914,7 @@ describe('Plugin Extensions / Utils', () => {
extensions: {
addedLinks: [],
addedComponents: [],
+ addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx
index 12b146bbf5c..f083ff738ea 100644
--- a/public/app/features/plugins/extensions/validators.test.tsx
+++ b/public/app/features/plugins/extensions/validators.test.tsx
@@ -271,6 +271,7 @@ describe('Plugin Extension Validators', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
};
const extensionConfig = {
@@ -387,6 +388,7 @@ describe('Plugin Extension Validators', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
};
const extensionConfig = {
@@ -503,6 +505,7 @@ describe('Plugin Extension Validators', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
};
const exposedComponentConfig = {
@@ -688,6 +691,7 @@ describe('Plugin Extension Validators', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
+ addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',
diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts
index b1dbd5e9af8..cbdaf81e935 100644
--- a/public/app/features/plugins/extensions/validators.ts
+++ b/public/app/features/plugins/extensions/validators.ts
@@ -5,6 +5,7 @@ import type {
PluginContextType,
PluginExtensionAddedComponentConfig,
PluginExtensionExposedComponentConfig,
+ PluginExtensionAddedFunctionConfig,
} from '@grafana/data';
import { PluginAddedLinksConfigureFunc, PluginExtensionPoints } from '@grafana/data/src/types/pluginExtensions';
import { config, isPluginExtensionLink } from '@grafana/runtime';
@@ -160,6 +161,38 @@ export const isAddedLinkMetaInfoMissing = (
return false;
};
+export const isAddedFunctionMetaInfoMissing = (
+ pluginId: string,
+ metaInfo: PluginExtensionAddedFunctionConfig,
+ log: ExtensionsLog
+) => {
+ const logPrefix = 'Could not register function extension. Reason:';
+ const app = config.apps[pluginId];
+ const pluginJsonMetaInfo = app ? app.extensions.addedFunctions.find(({ title }) => title === metaInfo.title) : null;
+
+ if (!app) {
+ log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`);
+ return true;
+ }
+
+ if (!pluginJsonMetaInfo) {
+ log.error(`${logPrefix} ${errors.ADDED_FUNCTION_META_INFO_MISSING}`);
+ return true;
+ }
+
+ const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets];
+ if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) {
+ log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`);
+ return true;
+ }
+
+ if (pluginJsonMetaInfo.description !== metaInfo.description) {
+ log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO);
+ }
+
+ return false;
+};
+
export const isAddedComponentMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionAddedComponentConfig,
diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts
index 45732de7fb1..f6d9c1ac2a6 100644
--- a/public/app/features/plugins/importPanelPlugin.ts
+++ b/public/app/features/plugins/importPanelPlugin.ts
@@ -82,7 +82,6 @@ function getPanelPlugin(meta: PanelPluginMeta): Promise {
if (!plugin.panel && plugin.angularPanelCtrl) {
plugin.panel = getAngularPanelReactWrapper(plugin);
}
-
return plugin;
})
.catch((err) => {
diff --git a/public/app/features/plugins/loader/sharedDependencies.ts b/public/app/features/plugins/loader/sharedDependencies.ts
index dbb8ed8692f..7059274c717 100644
--- a/public/app/features/plugins/loader/sharedDependencies.ts
+++ b/public/app/features/plugins/loader/sharedDependencies.ts
@@ -18,6 +18,7 @@ import { appEvents, contextSrv } from 'app/core/core';
import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv';
import impressionSrv from 'app/core/services/impression_srv';
import TimeSeries from 'app/core/time_series2';
+import { arrayMove } from 'app/core/utils/arrayMove';
import * as flatten from 'app/core/utils/flatten';
import kbn from 'app/core/utils/kbn';
import * as ticks from 'app/core/utils/ticks';
@@ -90,7 +91,8 @@ export const sharedDependenciesMap = {
__useDefault: true,
},
...jQueryFlotDeps,
- lodash: () => import('lodash').then((module) => ({ ...module, __useDefault: true })),
+ // add move to lodash for backward compatabilty with plugins
+ lodash: () => import('lodash').then((module) => ({ ...module, move: arrayMove, __useDefault: true })),
moment: () => import('moment').then((module) => ({ ...module, __useDefault: true })),
prismjs: () => import('prismjs'),
react: () => import('react'),
diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts
index aa20ae5881f..98915c40f6d 100644
--- a/public/app/features/plugins/plugin_loader.ts
+++ b/public/app/features/plugins/plugin_loader.ts
@@ -13,7 +13,12 @@ import { DataQuery } from '@grafana/schema';
import { GenericDataSourcePlugin } from '../datasources/types';
import builtInPlugins from './built_in_plugins';
-import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from './extensions/registry/setup';
+import {
+ addedComponentsRegistry,
+ addedFunctionsRegistry,
+ addedLinksRegistry,
+ exposedComponentsRegistry,
+} from './extensions/registry/setup';
import { getPluginFromCache, registerPluginInCache } from './loader/cache';
// SystemJS has to be imported before the sharedDependenciesMap
import { SystemJS } from './loader/systemjs';
@@ -153,7 +158,6 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise,
@@ -205,6 +209,10 @@ export async function importAppPlugin(meta: PluginMeta): Promise {
pluginId,
configs: plugin.addedLinkConfigs || [],
});
+ addedFunctionsRegistry.register({
+ pluginId,
+ configs: plugin.addedFunctionConfigs || [],
+ });
importedAppPlugins[pluginId] = plugin;
diff --git a/public/app/features/provisioning/ConfigForm.tsx b/public/app/features/provisioning/ConfigForm.tsx
index 00d8b338ba0..b9a9d061259 100644
--- a/public/app/features/provisioning/ConfigForm.tsx
+++ b/public/app/features/provisioning/ConfigForm.tsx
@@ -25,7 +25,7 @@ import { useCreateOrUpdateRepository } from './hooks';
import { RepositoryFormData, WorkflowOption } from './types';
import { dataToSpec, specToData } from './utils/data';
-const typeOptions = ['GitHub', 'Local', 'S3'].map((label) => ({ label, value: label.toLowerCase() }));
+const typeOptions = ['GitHub', 'Local'].map((label) => ({ label, value: label.toLowerCase() }));
const targetOptions = [
{ value: 'instance', label: 'Entire instance' },
{ value: 'folder', label: 'Managed folder' },
@@ -94,8 +94,13 @@ export function ConfigForm({ data }: ConfigFormProps) {
}
}, [request.isSuccess, reset, getValues, navigate]);
- const onSubmit = (data: RepositoryFormData) => {
- const spec = dataToSpec(data);
+ const onSubmit = (form: RepositoryFormData) => {
+ const spec = dataToSpec(form);
+ if (spec.github) {
+ spec.github.token = form.token || data?.spec?.github?.token;
+ // If we're still keeping this as GitHub, persist the old token. If we set a new one, it'll be re-encrypted into here.
+ spec.github.encryptedToken = data?.spec?.github?.encryptedToken;
+ }
submitData(spec);
};
@@ -196,16 +201,6 @@ export function ConfigForm({ data }: ConfigFormProps) {
)}
- {type === 's3' && (
-
-
-
-
-
-
-
-
- )}
@@ -228,7 +223,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
/>
-
+
diff --git a/public/app/features/provisioning/ExportToRepository.tsx b/public/app/features/provisioning/ExportToRepository.tsx
index 81a549c1f2b..562d3594eb8 100644
--- a/public/app/features/provisioning/ExportToRepository.tsx
+++ b/public/app/features/provisioning/ExportToRepository.tsx
@@ -1,7 +1,6 @@
-import { Controller, useForm } from 'react-hook-form';
+import { useForm } from 'react-hook-form';
import { Box, Button, Field, FieldSet, Input, Stack, Switch, Text } from '@grafana/ui';
-import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import ProgressBar from './ProgressBar';
import { Repository, useCreateRepositoryExportMutation, useListJobQuery, ExportJobOptions } from './api';
@@ -14,7 +13,7 @@ export function ExportToRepository({ repo }: Props) {
const [exportRepo, exportQuery] = useCreateRepositoryExportMutation();
const exportName = exportQuery.data?.metadata?.name;
- const { register, control, formState, handleSubmit } = useForm({
+ const { register, formState, handleSubmit } = useForm({
defaultValues: {
history: true,
prefix: '',
@@ -37,14 +36,6 @@ export function ExportToRepository({ repo }: Props) {
)}
diff --git a/public/app/features/search/service/unified.test.ts b/public/app/features/search/service/unified.test.ts
index e62a126f38f..8841418cd5a 100644
--- a/public/app/features/search/service/unified.test.ts
+++ b/public/app/features/search/service/unified.test.ts
@@ -115,9 +115,6 @@ describe('Unified Storage Searcher', () => {
.mockResolvedValueOnce(mockResults)
.mockResolvedValueOnce(mockFolders);
- const consoleWarn = jest.fn();
- jest.spyOn(console, 'warn').mockImplementationOnce(consoleWarn);
-
const query: SearchQuery = {
query: 'test',
limit: 50,
@@ -127,14 +124,15 @@ describe('Unified Storage Searcher', () => {
const response = await searcher.search(query);
- expect(response.view.length).toBe(1);
- expect(response.view.get(0).title).toBe('DB 2');
+ expect(response.view.length).toBe(2);
+ expect(response.view.get(0).title).toBe('DB 1');
+ expect(response.view.get(0).folder).toBe('sharedwithme');
+ expect(response.view.get(1).title).toBe('DB 2');
const df = response.view.dataFrame;
const locationInfo = df.meta?.custom?.locationInfo;
expect(locationInfo).toBeDefined();
expect(locationInfo?.folder2.name).toBe('Folder 2');
- expect(consoleWarn).toHaveBeenCalled();
expect(mockSearcher.search).toHaveBeenCalledTimes(3);
});
diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts
index 9d942f668d3..8e270aba37b 100644
--- a/public/app/features/search/service/unified.ts
+++ b/public/app/features/search/service/unified.ts
@@ -204,15 +204,19 @@ export class UnifiedSearcher implements GrafanaSearcher {
if (!hasMissing) {
return rsp;
}
- // we still have results here with folders we can't find
- // filter the results since we probably don't have access to that folder
+
const locationInfo = await this.locationInfo;
- const hits = rsp.hits.filter((hit) => {
- if (hit.folder === undefined || locationInfo[hit.folder] !== undefined) {
- return true;
+ const hits = rsp.hits.map((hit) => {
+ if (hit.folder === undefined) {
+ return { ...hit, location: 'general', folder: 'general' };
}
- console.warn('Dropping search hit with missing folder', hit);
- return false;
+
+ // this means user has permission to see this dashboard, but not the folder contents
+ if (locationInfo[hit.folder] === undefined) {
+ return { ...hit, location: 'sharedwithme', folder: 'sharedwithme' };
+ }
+
+ return hit;
});
const totalHits = rsp.totalHits - (rsp.hits.length - hits.length);
return { ...rsp, hits, totalHits };
@@ -370,6 +374,11 @@ async function loadLocationInfo(): Promise
> {
name: 'Dashboards',
url: '/dashboards',
}, // share location info with everyone
+ sharedwithme: {
+ kind: 'sharedwithme',
+ name: 'Shared with me',
+ url: '',
+ },
};
for (const hit of rsp.hits) {
locationInfo[hit.name] = {
diff --git a/public/app/features/search/service/utils.ts b/public/app/features/search/service/utils.ts
index 6aeeaefba92..85c0168e480 100644
--- a/public/app/features/search/service/utils.ts
+++ b/public/app/features/search/service/utils.ts
@@ -49,6 +49,10 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
return isOpen ? 'folder-open' : 'folder';
}
+ if (kind === 'sharedwithme') {
+ return 'users-alt';
+ }
+
return 'question-circle';
}
diff --git a/public/app/features/trails/DataTrail.test.tsx b/public/app/features/trails/DataTrail.test.tsx
index 4d0d0af86c4..ecb928383d2 100644
--- a/public/app/features/trails/DataTrail.test.tsx
+++ b/public/app/features/trails/DataTrail.test.tsx
@@ -44,14 +44,6 @@ describe('DataTrail', () => {
let trail: DataTrail;
const preTrailUrl = '/';
- function getFilterVar() {
- const variable = sceneGraph.lookupVariable(VAR_FILTERS, trail);
- if (variable instanceof AdHocFiltersVariable) {
- return variable;
- }
- throw new Error('getFilterVar failed');
- }
-
function getStepFilterVar(step: number) {
const variable = trail.state.history.state.steps[step].trailState.$variables?.getByName(VAR_FILTERS);
if (variable instanceof AdHocFiltersVariable) {
@@ -226,12 +218,12 @@ describe('DataTrail', () => {
});
it('Should have default empty filter', () => {
- expect(getFilterVar().state.filters.length).toBe(0);
+ expect(getFilterVar(trail).state.filters.length).toBe(0);
});
describe('And when changing the filter to zone=a', () => {
beforeEach(() => {
- getFilterVar().setState({ filters: [{ key: 'zone', operator: '=', value: 'a' }] });
+ getFilterVar(trail).setState({ filters: [{ key: 'zone', operator: '=', value: 'a' }] });
});
it('should add history step', () => {
@@ -247,8 +239,8 @@ describe('DataTrail', () => {
});
it('Should have filter be updated to "zone=a"', () => {
- expect(getFilterVar().state.filters[0].key).toBe('zone');
- expect(getFilterVar().state.filters[0].value).toBe('a');
+ expect(getFilterVar(trail).state.filters[0].key).toBe('zone');
+ expect(getFilterVar(trail).state.filters[0].value).toBe('a');
});
it('Previous history step should have empty filter', () => {
@@ -274,12 +266,12 @@ describe('DataTrail', () => {
});
it('Should have filters set back to empty', () => {
- expect(getFilterVar().state.filters.length).toBe(0);
+ expect(getFilterVar(trail).state.filters.length).toBe(0);
});
describe('And when changing the filter to zone=b', () => {
beforeEach(() => {
- getFilterVar().setState({ filters: [{ key: 'zone', operator: '=', value: 'b' }] });
+ getFilterVar(trail).setState({ filters: [{ key: 'zone', operator: '=', value: 'b' }] });
});
it('should add history step', () => {
@@ -295,8 +287,8 @@ describe('DataTrail', () => {
});
it('Should have filter be updated to "zone=b"', () => {
- expect(getFilterVar().state.filters[0].key).toBe('zone');
- expect(getFilterVar().state.filters[0].value).toBe('b');
+ expect(getFilterVar(trail).state.filters[0].key).toBe('zone');
+ expect(getFilterVar(trail).state.filters[0].value).toBe('b');
});
it('Parent history step 1 should still have empty filter', () => {
@@ -327,7 +319,7 @@ describe('DataTrail', () => {
});
it('Should have filters set back to empty', () => {
- expect(getFilterVar().state.filters.length).toBe(0);
+ expect(getFilterVar(trail).state.filters.length).toBe(0);
});
it('History step 1 should still have empty filter', () => {
@@ -417,12 +409,12 @@ describe('DataTrail', () => {
describe('And filter is added zone=a', () => {
beforeEach(() => {
- getFilterVar().setState({ filters: [{ key: 'zone', operator: '=', value: 'a' }] });
+ getFilterVar(trail).setState({ filters: [{ key: 'zone', operator: '=', value: 'a' }] });
});
it('Filter of trail should be zone=a', () => {
- expect(getFilterVar().state.filters[0].key).toBe('zone');
- expect(getFilterVar().state.filters[0].value).toBe('a');
+ expect(getFilterVar(trail).state.filters[0].key).toBe('zone');
+ expect(getFilterVar(trail).state.filters[0].value).toBe('a');
});
it('Filter of step 2 should be zone=a', () => {
@@ -440,7 +432,7 @@ describe('DataTrail', () => {
});
it('Filter of trail should be empty', () => {
- expect(getFilterVar().state.filters.length).toBe(0);
+ expect(getFilterVar(trail).state.filters.length).toBe(0);
});
});
});
@@ -518,14 +510,6 @@ describe('DataTrail', () => {
throw new Error('getOtelGroupLeftVar failed');
}
- function getFilterVar() {
- const variable = sceneGraph.lookupVariable(VAR_FILTERS, trail);
- if (variable instanceof AdHocFiltersVariable) {
- return variable;
- }
- throw new Error('getFilterVar failed');
- }
-
beforeEach(() => {
trail = new DataTrail({
nonPromotedOtelResources,
@@ -540,7 +524,7 @@ describe('DataTrail', () => {
it('clicking start button should start with OTel off and showing var filters', () => {
trail.setState({ startButtonClicked: true });
const otelResourcesHide = getOtelResourcesVar(trail).state.hide;
- const varFiltersHide = getFilterVar().state.hide;
+ const varFiltersHide = getFilterVar(trail).state.hide;
expect(otelResourcesHide).toBe(VariableHide.hideVariable);
expect(varFiltersHide).toBe(VariableHide.hideLabel);
});
@@ -557,7 +541,7 @@ describe('DataTrail', () => {
describe('resetting the OTel experience', () => {
it('should display with hideLabel var filters and hide VAR_OTEL_AND_METRIC_FILTERS when resetting otel experience', () => {
trail.resetOtelExperience();
- expect(getFilterVar().state.hide).toBe(VariableHide.hideLabel);
+ expect(getFilterVar(trail).state.hide).toBe(VariableHide.hideLabel);
expect(getOtelAndMetricsVar(trail).state.hide).toBe(VariableHide.hideVariable);
});
@@ -589,7 +573,7 @@ describe('DataTrail', () => {
it('should automatically update the var filters when a promoted resource has been selected from VAR_OTEL_AND_METRICS', () => {
getOtelAndMetricsVar(trail).setState({ filters: [{ key: 'promoted', operator: '=', value: 'resource' }] });
- const varFilters = getFilterVar().state.filters[0];
+ const varFilters = getFilterVar(trail).state.filters[0];
expect(varFilters.key).toBe('promoted');
expect(varFilters.value).toBe('resource');
});
@@ -600,4 +584,26 @@ describe('DataTrail', () => {
});
});
});
+
+ describe('Label filters', () => {
+ let trail: DataTrail;
+
+ beforeEach(() => {
+ trail = new DataTrail({});
+ });
+
+ it('should not escape regex metacharacters in label values', () => {
+ const filterVar = getFilterVar(trail);
+ filterVar.setState({ filters: [{ key: 'app', operator: '=~', value: '.*end' }] }); // matches app=frontend, app=backend, etc.
+ expect(filterVar.getValue()).toBe('app=~".*end"');
+ });
+ });
});
+
+function getFilterVar(trail: DataTrail) {
+ const variable = sceneGraph.lookupVariable(VAR_FILTERS, trail);
+ if (variable instanceof AdHocFiltersVariable) {
+ return variable;
+ }
+ throw new Error('getFilterVar failed');
+}
diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx
index fa85dd32773..aef53bad053 100644
--- a/public/app/features/trails/DataTrail.tsx
+++ b/public/app/features/trails/DataTrail.tsx
@@ -650,10 +650,9 @@ function getVariableSet(
addFilterButtonText: 'Select resource attributes',
datasource: trailDS,
hide: VariableHide.hideVariable,
- layout: 'vertical',
+ layout: 'combobox',
defaultKeys: [],
applyMode: 'manual',
- supportsMultiValueOperators: true,
allowCustomValue: true,
}),
new AdHocFiltersVariable({
@@ -662,13 +661,16 @@ function getVariableSet(
datasource: trailDS,
// default to use var filters and have otel off
hide: VariableHide.hideLabel,
- layout: 'vertical',
+ layout: 'combobox',
filters: initialFilters ?? [],
baseFilters: getBaseFiltersForMetric(metric),
applyMode: 'manual',
- // since we only support prometheus datasources, this is always true
- supportsMultiValueOperators: true,
allowCustomValue: true,
+ expressionBuilder: (filters: AdHocVariableFilter[]) => {
+ return [...getBaseFiltersForMetric(metric), ...filters]
+ .map((filter) => `${filter.key}${filter.operator}"${filter.value}"`)
+ .join(',');
+ },
}),
...getVariablesWithOtelJoinQueryConstant(otelJoinQuery ?? ''),
new ConstantVariable({
@@ -686,12 +688,10 @@ function getVariableSet(
addFilterButtonText: 'Filter',
datasource: trailDS,
hide: VariableHide.hideVariable,
- layout: 'vertical',
+ layout: 'combobox',
filters: initialFilters ?? [],
baseFilters: getBaseFiltersForMetric(metric),
applyMode: 'manual',
- // since we only support prometheus datasources, this is always true
- supportsMultiValueOperators: true,
allowCustomValue: true,
// skipUrlSync: true
}),
diff --git a/public/app/features/trails/banners/NativeHistogramBanner.test.tsx b/public/app/features/trails/banners/NativeHistogramBanner.test.tsx
index d5191037dae..23d4d4f21d1 100644
--- a/public/app/features/trails/banners/NativeHistogramBanner.test.tsx
+++ b/public/app/features/trails/banners/NativeHistogramBanner.test.tsx
@@ -51,4 +51,11 @@ describe('NativeHistogramBanner', () => {
fireEvent.click(histogramButton);
expect(mockTrail.publishEvent).toHaveBeenCalledWith(new MetricSelectedEvent('histogram1'), true);
});
+
+ test('Set that the banner has been shown in local storage when a user closes the banner', () => {
+ render( );
+ // click the button with aria label "Close alert"
+ fireEvent.click(screen.getByLabelText('Close alert'));
+ expect(localStorage.getItem('nativeHistogramBanner')).toBe('true');
+ });
});
diff --git a/public/app/features/trails/banners/NativeHistogramBanner.tsx b/public/app/features/trails/banners/NativeHistogramBanner.tsx
index 68ca35c0c67..d32375e054e 100644
--- a/public/app/features/trails/banners/NativeHistogramBanner.tsx
+++ b/public/app/features/trails/banners/NativeHistogramBanner.tsx
@@ -21,7 +21,7 @@ export function NativeHistogramBanner(props: NativeHistogramInfoProps) {
const [showHistogramExamples, setShowHistogramExamples] = useState(false);
const styles = useStyles2(getStyles, 0);
- if (!histogramsLoaded || nativeHistograms.length === 0 || !histogramMessage) {
+ if (bannerHasBeenShown() || !histogramsLoaded || nativeHistograms.length === 0 || !histogramMessage) {
return null;
}
@@ -32,6 +32,8 @@ export function NativeHistogramBanner(props: NativeHistogramInfoProps) {
title={'Native Histogram Support'}
severity={'info'}
onRemove={() => {
+ // when a user explicitly closes the banner, save that it has been closed in local storage to not show again
+ setBannerHasBeenShown();
setHistogramMessage(false);
}}
className={styles.banner}
@@ -275,3 +277,11 @@ function getStyles(theme: GrafanaTheme2, _chromeHeaderHeight: number) {
}),
};
}
+
+export function setBannerHasBeenShown() {
+ localStorage.setItem('nativeHistogramBanner', 'true');
+}
+
+export function bannerHasBeenShown() {
+ return localStorage.getItem('nativeHistogramBanner') ?? false;
+}
diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/dashboards/streaming.json b/public/app/plugins/datasource/grafana-testdata-datasource/dashboards/streaming.json
index 8498d79f2f1..b5bd877bcaa 100644
--- a/public/app/plugins/datasource/grafana-testdata-datasource/dashboards/streaming.json
+++ b/public/app/plugins/datasource/grafana-testdata-datasource/dashboards/streaming.json
@@ -199,8 +199,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Simple Streaming Example",
diff --git a/public/app/plugins/datasource/graphite/dashboards/carbon_metrics.json b/public/app/plugins/datasource/graphite/dashboards/carbon_metrics.json
index 94e8e685d45..418ba46835f 100644
--- a/public/app/plugins/datasource/graphite/dashboards/carbon_metrics.json
+++ b/public/app/plugins/datasource/graphite/dashboards/carbon_metrics.json
@@ -154,7 +154,6 @@
"now": true,
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
"status": "Stable",
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"],
"type": "timepicker"
},
"templating": {
diff --git a/public/app/plugins/datasource/graphite/dashboards/metrictank.json b/public/app/plugins/datasource/graphite/dashboards/metrictank.json
index 18b2e3939ff..b70ad4d58ec 100644
--- a/public/app/plugins/datasource/graphite/dashboards/metrictank.json
+++ b/public/app/plugins/datasource/graphite/dashboards/metrictank.json
@@ -4771,7 +4771,6 @@
"now": true,
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
"status": "Stable",
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"],
"type": "timepicker"
},
"timezone": "utc",
diff --git a/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json b/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json
index a121de7fe5a..d465f31c1f5 100644
--- a/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json
+++ b/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json
@@ -1177,8 +1177,7 @@
"to": "now"
},
"timepicker": {
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "",
"title": "Grafana metrics",
diff --git a/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json b/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json
index 3d4cea64f05..57e2bb5e47d 100644
--- a/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json
+++ b/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json
@@ -1393,8 +1393,7 @@
},
"timepicker": {
"now": true,
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "browser",
"title": "Prometheus 2.0 Stats",
diff --git a/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json b/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json
index 9169006b895..383f75c9011 100644
--- a/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json
+++ b/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json
@@ -824,8 +824,7 @@
},
"timepicker": {
"now": true,
- "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "browser",
"title": "Prometheus Stats",
diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx
index dba432c57d0..424bbd16de0 100644
--- a/public/app/plugins/panel/barchart/BarChartPanel.tsx
+++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx
@@ -157,8 +157,8 @@ export const BarChartPanel = (props: PanelProps) => {
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
}
- getDataLinks={(seriesIdx: number, dataIdx: number) =>
- vizSeries[0].fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? []
+ getDataLinks={(seriesIdx, dataIdx) =>
+ vizSeries[0].fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? []
}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => {
return (
diff --git a/public/app/plugins/panel/barchart/module.tsx b/public/app/plugins/panel/barchart/module.tsx
index 5b5ae542531..c31c17f5a81 100644
--- a/public/app/plugins/panel/barchart/module.tsx
+++ b/public/app/plugins/panel/barchart/module.tsx
@@ -32,6 +32,14 @@ export const plugin = new PanelPlugin(BarChartPanel)
mode: FieldColorModeId.PaletteClassic,
},
},
+ [FieldConfigProperty.Links]: {
+ settings: {
+ showOneClick: true,
+ },
+ },
+ [FieldConfigProperty.Actions]: {
+ hideFromDefaults: false,
+ },
},
useCustomConfig: (builder) => {
const cfg = defaultFieldConfig;
diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx
index 4ed97d59456..16eeeae3ca6 100644
--- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx
+++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx
@@ -282,8 +282,8 @@ export const CandlestickPanel = ({
clientZoom={true}
syncMode={cursorSync}
syncScope={eventsScope}
- getDataLinks={(seriesIdx: number, dataIdx: number) =>
- alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? []
+ getDataLinks={(seriesIdx, dataIdx) =>
+ alignedFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? []
}
render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync, dataLinks) => {
if (enableAnnotationCreation && timeRange2 != null) {
diff --git a/public/app/plugins/panel/canvas/CanvasPanel.tsx b/public/app/plugins/panel/canvas/CanvasPanel.tsx
index cd48d2ed8dd..4db38108706 100644
--- a/public/app/plugins/panel/canvas/CanvasPanel.tsx
+++ b/public/app/plugins/panel/canvas/CanvasPanel.tsx
@@ -85,6 +85,7 @@ export class CanvasPanel extends Component {
this.scene.setBackgroundCallback = this.openSetBackground;
this.scene.tooltipCallback = this.tooltipCallback;
this.scene.moveableActionCallback = this.moveableActionCallback;
+ this.scene.actionConfirmationCallback = this.actionConfirmationCallback;
this.subs.add(
this.props.eventBus.subscribe(PanelEditEnteredEvent, (evt: PanelEditEnteredEvent) => {
@@ -302,6 +303,10 @@ export class CanvasPanel extends Component {
this.forceUpdate();
};
+ actionConfirmationCallback = () => {
+ this.forceUpdate();
+ };
+
closeInlineEdit = () => {
this.setState({ openInlineEdit: false });
isInlineEditOpen = false;
diff --git a/public/app/plugins/panel/canvas/components/CanvasTooltip.tsx b/public/app/plugins/panel/canvas/components/CanvasTooltip.tsx
index 39390afefdf..f2a5959edc6 100644
--- a/public/app/plugins/panel/canvas/components/CanvasTooltip.tsx
+++ b/public/app/plugins/panel/canvas/components/CanvasTooltip.tsx
@@ -88,7 +88,6 @@ export const CanvasTooltip = ({ scene }: Props) => {
}
});
}
- // ---------
if (scene.data?.series) {
getElementFields(scene.data?.series, element.options).forEach((field) => {
diff --git a/public/app/plugins/panel/canvas/editor/element/DataLinksEditor.tsx b/public/app/plugins/panel/canvas/editor/element/DataLinksEditor.tsx
index 9849b35d368..16af0cd074b 100644
--- a/public/app/plugins/panel/canvas/editor/element/DataLinksEditor.tsx
+++ b/public/app/plugins/panel/canvas/editor/element/DataLinksEditor.tsx
@@ -20,7 +20,7 @@ export function DataLinksEditor({ value, onChange, item, context }: Props) {
}}
getSuggestions={() => (context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])}
data={[]}
- showOneClick={false}
+ showOneClick={true}
/>
);
}
diff --git a/public/app/plugins/panel/canvas/editor/element/elementEditor.tsx b/public/app/plugins/panel/canvas/editor/element/elementEditor.tsx
index 5e3a545b87b..78562e83e88 100644
--- a/public/app/plugins/panel/canvas/editor/element/elementEditor.tsx
+++ b/public/app/plugins/panel/canvas/editor/element/elementEditor.tsx
@@ -1,6 +1,5 @@
-import { capitalize, get as lodashGet } from 'lodash';
+import { get as lodashGet } from 'lodash';
-import { OneClickMode } from '@grafana/data';
import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
import { config } from '@grafana/runtime';
import { CanvasElementOptions } from 'app/features/canvas/element';
@@ -123,36 +122,10 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<
optionBuilder.addBorder(builder, ctx);
}
- const oneClickModeOptions: Array<{
- value: OneClickMode;
- label: string;
- }> = [
- { value: OneClickMode.Off, label: capitalize(OneClickMode.Off) },
- { value: OneClickMode.Link, label: capitalize(OneClickMode.Link) },
- ];
-
- let oneClickCategory = 'Data links';
- let oneClickDescription = 'When enabled, a single click opens the first link';
-
- if (actionsEnabled) {
- oneClickModeOptions.push({ value: OneClickMode.Action, label: capitalize(OneClickMode.Action) });
- oneClickCategory += ' and actions';
- oneClickDescription += ' or action';
- }
-
- builder.addRadio({
- category: [oneClickCategory],
- path: 'oneClickMode',
- name: 'One-click',
- description: oneClickDescription,
- settings: {
- options: oneClickModeOptions,
- },
- defaultValue: OneClickMode.Off,
- });
-
optionBuilder.addDataLinks(builder, ctx);
- optionBuilder.addActions(builder, ctx);
+ if (actionsEnabled) {
+ optionBuilder.addActions(builder, ctx);
+ }
},
};
}
diff --git a/public/app/plugins/panel/canvas/migrations.test.ts b/public/app/plugins/panel/canvas/migrations.test.ts
index 0a1354b2ae0..2c66529bd1c 100644
--- a/public/app/plugins/panel/canvas/migrations.test.ts
+++ b/public/app/plugins/panel/canvas/migrations.test.ts
@@ -1,4 +1,4 @@
-import { OneClickMode, PanelModel } from '@grafana/data';
+import { PanelModel } from '@grafana/data';
import { canvasMigrationHandler } from './migrations';
@@ -20,6 +20,16 @@ describe('Canvas migration', () => {
},
},
],
+ links: [
+ {
+ title: 'Link1',
+ url: 'www.link1.com',
+ },
+ {
+ title: 'Link2',
+ url: 'www.link2.com',
+ },
+ ],
},
],
},
@@ -29,7 +39,7 @@ describe('Canvas migration', () => {
panel.options = canvasMigrationHandler(panel);
- expect(panel.options.root.elements[0].oneClickMode).toBe(OneClickMode.Link);
+ expect(panel.options.root.elements[0].links[0].oneClick).toBe(true);
expect(panel.options.root.elements[0].actions[0].fetch.url).toBe('http://test.com');
});
});
diff --git a/public/app/plugins/panel/canvas/migrations.ts b/public/app/plugins/panel/canvas/migrations.ts
index f287556a2f7..479a540bff7 100644
--- a/public/app/plugins/panel/canvas/migrations.ts
+++ b/public/app/plugins/panel/canvas/migrations.ts
@@ -46,12 +46,6 @@ export const canvasMigrationHandler = (panel: PanelModel): Partial => {
const root = panel.options?.root;
if (root?.elements) {
for (const element of root.elements) {
- // migrate oneClickLinks to oneClickMode
- if (element.oneClickLinks) {
- element.oneClickMode = OneClickMode.Link;
- delete element.oneClickLinks;
- }
-
// migrate action options to new format (fetch)
if (element.actions) {
for (const action of element.actions) {
@@ -65,5 +59,22 @@ export const canvasMigrationHandler = (panel: PanelModel): Partial => {
}
}
+ // migrate oneClickMode to first link/action oneClick
+ if (parseFloat(pluginVersion) <= 11.6) {
+ const root = panel.options?.root;
+ if (root?.elements) {
+ for (const element of root.elements) {
+ if (element.oneClickMode === OneClickMode.Link || element.oneClickLinks) {
+ element.links[0].oneClick = true;
+ } else if (element.oneClickMode === OneClickMode.Action) {
+ element.actions[0].oneClick = true;
+ }
+
+ delete element.oneClickMode;
+ delete element.oneClickLinks;
+ }
+ }
+ }
+
return panel.options;
};
diff --git a/public/app/plugins/panel/canvas/module.tsx b/public/app/plugins/panel/canvas/module.tsx
index 19b9ce04ded..011c9e78682 100644
--- a/public/app/plugins/panel/canvas/module.tsx
+++ b/public/app/plugins/panel/canvas/module.tsx
@@ -59,7 +59,14 @@ export const plugin = new PanelPlugin(CanvasPanel)
},
},
[FieldConfigProperty.Links]: {
- hideFromDefaults: true,
+ settings: {
+ showOneClick: false,
+ },
+ },
+ [FieldConfigProperty.Actions]: {
+ settings: {
+ showOneClick: false,
+ },
},
},
})
diff --git a/public/app/plugins/panel/heatmap/module.tsx b/public/app/plugins/panel/heatmap/module.tsx
index f710858e0ec..6ec45e27d9a 100644
--- a/public/app/plugins/panel/heatmap/module.tsx
+++ b/public/app/plugins/panel/heatmap/module.tsx
@@ -23,6 +23,13 @@ import { Options, defaultOptions, HeatmapColorMode, HeatmapColorScale } from './
export const plugin = new PanelPlugin(HeatmapPanel)
.useFieldConfig({
disableStandardOptions: Object.values(FieldConfigProperty).filter((v) => v !== FieldConfigProperty.Links),
+ standardOptions: {
+ [FieldConfigProperty.Links]: {
+ settings: {
+ showOneClick: true,
+ },
+ },
+ },
useCustomConfig: (builder) => {
builder.addCustomEditor({
id: 'scaleDistribution',
diff --git a/public/app/plugins/panel/histogram/module.tsx b/public/app/plugins/panel/histogram/module.tsx
index c73870cf739..3be418ceb87 100644
--- a/public/app/plugins/panel/histogram/module.tsx
+++ b/public/app/plugins/panel/histogram/module.tsx
@@ -81,6 +81,11 @@ export const plugin = new PanelPlugin(HistogramPanel)
mode: FieldColorModeId.PaletteClassic,
},
},
+ [FieldConfigProperty.Links]: {
+ settings: {
+ showOneClick: true,
+ },
+ },
},
useCustomConfig: (builder) => {
const cfg = defaultFieldConfig;
diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx
index 9524beba351..7a23e4a6898 100644
--- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx
+++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx
@@ -107,8 +107,8 @@ export const StateTimelinePanel = ({
queryZoom={onChangeTimeRange}
syncMode={cursorSync}
syncScope={eventsScope}
- getDataLinks={(seriesIdx: number, dataIdx: number) =>
- alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? []
+ getDataLinks={(seriesIdx, dataIdx) =>
+ alignedFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? []
}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => {
if (enableAnnotationCreation && timeRange2 != null) {
diff --git a/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx b/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx
index a2f2665a11d..b400b9e6e73 100644
--- a/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx
+++ b/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx
@@ -68,12 +68,16 @@ export const StateTimelineTooltip2 = ({
let footer: ReactNode;
- if (isPinned && seriesIdx != null) {
+ if (seriesIdx != null) {
const field = series.fields[seriesIdx];
- const dataIdx = dataIdxs[seriesIdx]!;
- const actions = getFieldActions(series, field, replaceVariables!, dataIdx);
+ const hasOneClickLink = dataLinks.some((dataLink) => dataLink.oneClick === true);
- footer = ;
+ if (isPinned || hasOneClickLink) {
+ const dataIdx = dataIdxs[seriesIdx]!;
+ const actions = getFieldActions(series, field, replaceVariables!, dataIdx);
+
+ footer = ;
+ }
}
const headerItem: VizTooltipItem = {
diff --git a/public/app/plugins/panel/state-timeline/module.tsx b/public/app/plugins/panel/state-timeline/module.tsx
index 024d1e883ac..7d84934e74a 100644
--- a/public/app/plugins/panel/state-timeline/module.tsx
+++ b/public/app/plugins/panel/state-timeline/module.tsx
@@ -29,6 +29,14 @@ export const plugin = new PanelPlugin(StateTimelinePanel)
mode: FieldColorModeId.ContinuousGrYlRd,
},
},
+ [FieldConfigProperty.Links]: {
+ settings: {
+ showOneClick: true,
+ },
+ },
+ [FieldConfigProperty.Actions]: {
+ hideFromDefaults: false,
+ },
},
useCustomConfig: (builder) => {
builder
diff --git a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx
index 8ed1a266a27..1f410d1578c 100644
--- a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx
+++ b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx
@@ -119,8 +119,8 @@ export const StatusHistoryPanel = ({
queryZoom={onChangeTimeRange}
syncMode={cursorSync}
syncScope={eventsScope}
- getDataLinks={(seriesIdx: number, dataIdx: number) =>
- alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? []
+ getDataLinks={(seriesIdx, dataIdx) =>
+ alignedFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? []
}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => {
if (enableAnnotationCreation && timeRange2 != null) {
diff --git a/public/app/plugins/panel/status-history/module.tsx b/public/app/plugins/panel/status-history/module.tsx
index 722f8936199..d556c52ccc0 100644
--- a/public/app/plugins/panel/status-history/module.tsx
+++ b/public/app/plugins/panel/status-history/module.tsx
@@ -17,6 +17,14 @@ export const plugin = new PanelPlugin(StatusHistoryPanel)
mode: FieldColorModeId.Thresholds,
},
},
+ [FieldConfigProperty.Links]: {
+ settings: {
+ showOneClick: true,
+ },
+ },
+ [FieldConfigProperty.Actions]: {
+ hideFromDefaults: false,
+ },
},
useCustomConfig: (builder) => {
builder
diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx
index c83af4b2703..f8866b705f3 100644
--- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx
+++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx
@@ -106,8 +106,8 @@ export const TimeSeriesPanel = ({
clientZoom={true}
syncMode={cursorSync}
syncScope={eventsScope}
- getDataLinks={(seriesIdx: number, dataIdx: number) =>
- alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? []
+ getDataLinks={(seriesIdx, dataIdx) =>
+ alignedFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? []
}
render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync, dataLinks) => {
if (enableAnnotationCreation && timeRange2 != null) {
diff --git a/public/app/plugins/panel/timeseries/config.ts b/public/app/plugins/panel/timeseries/config.ts
index f749b1f6e6c..39e6a283d01 100644
--- a/public/app/plugins/panel/timeseries/config.ts
+++ b/public/app/plugins/panel/timeseries/config.ts
@@ -59,6 +59,14 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig, isTime = true): SetFi
mode: FieldColorModeId.PaletteClassic,
},
},
+ [FieldConfigProperty.Links]: {
+ settings: {
+ showOneClick: true,
+ },
+ },
+ [FieldConfigProperty.Actions]: {
+ hideFromDefaults: false,
+ },
},
useCustomConfig: (builder) => {
builder
diff --git a/public/app/plugins/panel/trend/TrendPanel.tsx b/public/app/plugins/panel/trend/TrendPanel.tsx
index 67dcf81bb4e..1fe5ced2a49 100644
--- a/public/app/plugins/panel/trend/TrendPanel.tsx
+++ b/public/app/plugins/panel/trend/TrendPanel.tsx
@@ -119,8 +119,8 @@ export const TrendPanel = ({
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
}
- getDataLinks={(seriesIdx: number, dataIdx: number) =>
- alignedDataFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? []
+ getDataLinks={(seriesIdx, dataIdx) =>
+ alignedDataFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? []
}
render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange, viaSync, dataLinks) => {
return (
diff --git a/public/app/plugins/panel/xychart/XYChartPanel.tsx b/public/app/plugins/panel/xychart/XYChartPanel.tsx
index f22fd8248e1..25ef452dc6b 100644
--- a/public/app/plugins/panel/xychart/XYChartPanel.tsx
+++ b/public/app/plugins/panel/xychart/XYChartPanel.tsx
@@ -17,6 +17,8 @@ import {
import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
import { getDisplayValuesForCalcs } from '@grafana/ui/src/components/uPlot/utils';
+import { getDataLinks } from '../status-history/utils';
+
import { XYChartTooltip } from './XYChartTooltip';
import { Options } from './panelcfg.gen';
import { prepConfig } from './scatter';
@@ -113,7 +115,11 @@ export const XYChartPanel2 = (props: Props2) => {
{
+ getDataLinks={(seriesIdx, dataIdx) => {
+ const xySeries = series[seriesIdx - 1];
+ return getDataLinks(xySeries.y.field, dataIdx);
+ }}
+ render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => {
return (
{
isPinned={isPinned}
seriesIdx={seriesIdx!}
replaceVariables={props.replaceVariables}
+ dataLinks={dataLinks}
/>
);
}}
diff --git a/public/app/plugins/panel/xychart/XYChartTooltip.tsx b/public/app/plugins/panel/xychart/XYChartTooltip.tsx
index abd3cb32bc2..08bd3903bad 100644
--- a/public/app/plugins/panel/xychart/XYChartTooltip.tsx
+++ b/public/app/plugins/panel/xychart/XYChartTooltip.tsx
@@ -1,6 +1,6 @@
import { ReactNode } from 'react';
-import { DataFrame, InterpolateFunction } from '@grafana/data';
+import { DataFrame, InterpolateFunction, LinkModel } from '@grafana/data';
import { alpha } from '@grafana/data/src/themes/colorManipulator';
import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent';
import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter';
@@ -8,7 +8,7 @@ import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizToolt
import { VizTooltipWrapper } from '@grafana/ui/src/components/VizTooltip/VizTooltipWrapper';
import { ColorIndicator, VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types';
-import { getDataLinks, getFieldActions } from '../status-history/utils';
+import { getFieldActions } from '../status-history/utils';
import { XYSeries } from './types2';
import { fmt } from './utils';
@@ -21,6 +21,7 @@ export interface Props {
data: DataFrame[];
xySeries: XYSeries[];
replaceVariables: InterpolateFunction;
+ dataLinks: LinkModel[];
}
function stripSeriesName(fieldName: string, seriesName: string) {
@@ -31,7 +32,16 @@ function stripSeriesName(fieldName: string, seriesName: string) {
return fieldName;
}
-export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, xySeries, dismiss, isPinned, replaceVariables }: Props) => {
+export const XYChartTooltip = ({
+ dataIdxs,
+ seriesIdx,
+ data,
+ xySeries,
+ dismiss,
+ isPinned,
+ replaceVariables,
+ dataLinks,
+}: Props) => {
const rowIndex = dataIdxs.find((idx) => idx !== null)!;
const series = xySeries[seriesIdx! - 1];
@@ -93,12 +103,15 @@ export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, xySeries, dismiss, i
let footer: ReactNode;
- if (isPinned && seriesIdx != null) {
- const links = getDataLinks(yField, rowIndex);
- const yFieldFrame = data.find((frame) => frame.fields.includes(yField))!;
- const actions = getFieldActions(yFieldFrame, yField, replaceVariables, rowIndex);
+ if (seriesIdx != null) {
+ const hasOneClickLink = dataLinks?.some((dataLink) => dataLink.oneClick === true);
- footer = ;
+ if (isPinned || hasOneClickLink) {
+ const yFieldFrame = data.find((frame) => frame.fields.includes(yField))!;
+ const actions = getFieldActions(yFieldFrame, yField, replaceVariables, rowIndex);
+
+ footer = ;
+ }
}
return (
diff --git a/public/app/plugins/panel/xychart/config.ts b/public/app/plugins/panel/xychart/config.ts
index c3e958c8c12..bff3ffb8774 100644
--- a/public/app/plugins/panel/xychart/config.ts
+++ b/public/app/plugins/panel/xychart/config.ts
@@ -35,7 +35,6 @@ export function getScatterFieldConfig(cfg: FieldConfig): SetFieldConfigOptionsAr
[FieldConfigProperty.DisplayName]: {
hideFromDefaults: true,
},
-
// TODO: this still leaves Color series by: [ Last | Min | Max ]
// because item.settings?.bySeriesSupport && colorMode.isByValue
[FieldConfigProperty.Color]: {
@@ -48,6 +47,14 @@ export function getScatterFieldConfig(cfg: FieldConfig): SetFieldConfigOptionsAr
mode: FieldColorModeId.PaletteClassic,
},
},
+ [FieldConfigProperty.Links]: {
+ settings: {
+ showOneClick: true,
+ },
+ },
+ [FieldConfigProperty.Actions]: {
+ hideFromDefaults: false,
+ },
},
useCustomConfig: (builder) => {
diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx
index 62f6dc3ce83..0847577522f 100644
--- a/public/app/routes/routes.tsx
+++ b/public/app/routes/routes.tsx
@@ -97,8 +97,10 @@ export function getAppRoutes(): RouteDescriptor[] {
path: '/dashboard-solo/:type/:slug',
routeName: DashboardRoutes.Normal,
chromeless: true,
- component: SafeDynamicImport(
- () => import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage')
+ component: SafeDynamicImport(() =>
+ config.featureToggles.dashboardSceneSolo
+ ? import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard-scene/solo/SoloPanelPage')
+ : import(/* webpackChunkName: "SoloPanelPageOld" */ '../features/dashboard/containers/SoloPanelPage')
),
},
{
diff --git a/public/dashboards/default.json b/public/dashboards/default.json
index c59f98ec1dd..2b2ef5d1c9b 100644
--- a/public/dashboards/default.json
+++ b/public/dashboards/default.json
@@ -131,7 +131,6 @@
"collapse": false,
"enable": true,
"status": "Stable",
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"],
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
"now": true
}
diff --git a/public/dashboards/home.json b/public/dashboards/home.json
index 718b6b52079..8d5cfd00e52 100644
--- a/public/dashboards/home.json
+++ b/public/dashboards/home.json
@@ -68,7 +68,6 @@
"timepicker": {
"hidden": true,
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"],
"type": "timepicker"
},
"timezone": "browser",
diff --git a/public/dashboards/scripted.js b/public/dashboards/scripted.js
index daa4c4efb70..03de55bff44 100644
--- a/public/dashboards/scripted.js
+++ b/public/dashboards/scripted.js
@@ -13,16 +13,11 @@
'use strict';
-// accessible variables in this scope
-let window, document, $, jQuery, moment, kbn;
+// accessible variables in this scope: window, document, $, jQuery, moment, kbn;
// Setup some variables
let dashboard;
-// All url parameters are available via the ARGS object
-// eslint-disable-next-line no-redeclare
-let ARGS;
-
// Initialize a skeleton with nothing but a rows array and service object
dashboard = {
rows: [],
@@ -56,6 +51,7 @@ for (let i = 0; i < rows; i++) {
height: '300px',
panels: [
{
+ id: 1,
title: 'Events',
type: 'graph',
span: 12,
@@ -63,23 +59,17 @@ for (let i = 0; i < rows; i++) {
linewidth: 2,
targets: [
{
- target: "randomWalk('" + seriesName + "')",
+ scenarioId: 'random_walk',
+ refId: 'A',
+ seriesCount: 1,
+ alias: seriesName,
},
{
- target: "randomWalk('random walk2')",
+ scenarioId: 'random_walk',
+ refId: 'B',
+ seriesCount: 1,
},
],
- seriesOverrides: [
- {
- alias: '/random/',
- yaxis: 2,
- fill: 0,
- linewidth: 5,
- },
- ],
- tooltip: {
- shared: true,
- },
},
],
});
diff --git a/public/dashboards/scripted_async.js b/public/dashboards/scripted_async.js
index 98fb144d243..d13a80eb034 100644
--- a/public/dashboards/scripted_async.js
+++ b/public/dashboards/scripted_async.js
@@ -17,7 +17,7 @@
'use strict';
// accessible variables in this scope
-let window, document, ARGS, $, jQuery, moment, kbn;
+// let window, document, ARGS, $, jQuery, moment, kbn;
return function (callback) {
// Setup some variables
@@ -60,6 +60,7 @@ return function (callback) {
height: '300px',
panels: [
{
+ id: 1,
title: 'Async dashboard test',
type: 'text',
span: 12,
diff --git a/public/dashboards/scripted_templated.js b/public/dashboards/scripted_templated.js
index df8647ff86e..0b09fa50986 100644
--- a/public/dashboards/scripted_templated.js
+++ b/public/dashboards/scripted_templated.js
@@ -14,14 +14,14 @@
'use strict';
// accessible variables in this scope
-let window, document, $, jQuery, moment, kbn;
+// let window, document, $, jQuery, moment, kbn;
// Setup some variables
let dashboard;
// All url parameters are available via the ARGS object
// eslint-disable-next-line no-redeclare
-let ARGS;
+// let ARGS;
// Initialize a skeleton with nothing but a rows array and service object
dashboard = {
@@ -44,19 +44,22 @@ dashboard.templating = {
list: [
{
name: 'test',
- query: 'apps.backend.*',
- refresh: 1,
- type: 'query',
- datasource: null,
hide: 2,
+ includeAll: false,
+ multi: false,
+ query: 'a,b,c\n',
+ skipUrlSync: false,
+ type: 'custom',
},
{
- name: 'test2',
- query: '*',
- refresh: 1,
- type: 'query',
- datasource: null,
- hide: 2,
+ name: 'seriesName',
+ label: 'Series name',
+ hide: 0,
+ includeAll: false,
+ multi: false,
+ query: 'series1,series2,series3\n',
+ skipUrlSync: false,
+ type: 'custom',
},
],
};
@@ -78,6 +81,7 @@ for (let i = 0; i < rows; i++) {
height: '300px',
panels: [
{
+ id: 1,
title: 'Events',
type: 'graph',
span: 12,
@@ -85,10 +89,16 @@ for (let i = 0; i < rows; i++) {
linewidth: 2,
targets: [
{
- target: "randomWalk('" + seriesName + "')",
+ scenarioId: 'random_walk',
+ refId: 'A',
+ seriesCount: 1,
+ alias: seriesName,
},
{
- target: "randomWalk('[[test2]]')",
+ scenarioId: 'random_walk',
+ refId: 'B',
+ seriesCount: 1,
+ alias: '${seriesName}',
},
],
},
diff --git a/public/dashboards/template_vars.json b/public/dashboards/template_vars.json
index 33478bc8081..04cb1c95d79 100644
--- a/public/dashboards/template_vars.json
+++ b/public/dashboards/template_vars.json
@@ -169,7 +169,6 @@
"notice": false,
"enable": true,
"status": "Stable",
- "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"],
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
"now": true
}
diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json
index 46fe1314938..fed79005cc6 100644
--- a/public/locales/de-DE/grafana.json
+++ b/public/locales/de-DE/grafana.json
@@ -34,12 +34,6 @@
"save-button": ""
}
},
- "actions-editor": {
- "inline": {
- "add-button": "",
- "one-click-link": ""
- }
- },
"admin": {
"anon-users": {
"not-found": ""
@@ -174,6 +168,24 @@
}
},
"alerting": {
+ "alert": {
+ "alert-state": "",
+ "annotations": "",
+ "evaluation": "",
+ "evaluation-paused": "",
+ "evaluation-paused-description": "",
+ "last-evaluated": "",
+ "last-evaluation-duration": "",
+ "last-updated-at": "",
+ "last-updated-by": "",
+ "no-annotations": "",
+ "pending-period": "",
+ "rule": "",
+ "rule-identifier": "",
+ "rule-type": "",
+ "state-error-timeout": "",
+ "state-no-data": ""
+ },
"alert-recording-rule-form": {
"evaluation-behaviour": {
"description": {
@@ -283,6 +295,7 @@
"contactPointFilter": {
"label": ""
},
+ "copy-to-clipboard": "",
"export": {
"subtitle": {
"formats": "",
@@ -299,6 +312,13 @@
}
}
},
+ "group-actions": {
+ "actions-trigger": "",
+ "delete": "",
+ "edit": "",
+ "export": "",
+ "reorder": ""
+ },
"list-view": {
"empty": {
"new-alert-rule": "",
@@ -484,11 +504,23 @@
},
"rule-list": {
"configure-datasource": "",
+ "ds-error-boundary": {
+ "description": "",
+ "title": ""
+ },
"filter-view": {
"no-more-results": "",
"no-rules-found": ""
},
- "new-alert-rule": ""
+ "new-alert-rule": "",
+ "pagination": {
+ "next-page": "",
+ "previous-page": ""
+ },
+ "return-button": {
+ "title": ""
+ },
+ "rulerrule-loading-error": ""
},
"rule-state": {
"creating": "",
@@ -690,7 +722,10 @@
"title": ""
},
"custom-value": {
- "label": ""
+ "description": ""
+ },
+ "group": {
+ "undefined": ""
},
"options": {
"no-found": ""
@@ -863,6 +898,82 @@
"redirect-link": "Liste in Grafana Alerting",
"subtitle": "Benachrichtigungsregeln im Zusammenhang mit diesem Dashboard"
},
+ "default-layout": {
+ "description": "",
+ "item-options": {
+ "repeat": {
+ "direction": {
+ "horizontal": "",
+ "title": "",
+ "vertical": ""
+ },
+ "max": "",
+ "title": "",
+ "variable": {
+ "description": "",
+ "title": ""
+ }
+ }
+ },
+ "name": "",
+ "row-actions": {
+ "delete": "",
+ "modal": {
+ "alt-action": "",
+ "text": "",
+ "title": ""
+ }
+ },
+ "row-options": {
+ "button": {
+ "label": ""
+ },
+ "form": {
+ "cancel": "",
+ "repeat-for": {
+ "label": "",
+ "learn-more": "",
+ "warning": {
+ "text": ""
+ }
+ },
+ "title": "",
+ "update": ""
+ },
+ "modal": {
+ "title": ""
+ }
+ }
+ },
+ "edit-pane": {
+ "objects": {
+ "multi-select": {
+ "selection-number": ""
+ }
+ },
+ "open": "",
+ "panels": {
+ "multi-select": {
+ "selection-number": ""
+ }
+ },
+ "row": {
+ "header": {
+ "hide": "",
+ "title": ""
+ },
+ "multi-select": {
+ "options-header": "",
+ "selection-number": ""
+ }
+ },
+ "tab": {
+ "multi-select": {
+ "options-header": "",
+ "selection-number": ""
+ }
+ }
+ },
"empty": {
"add-library-panel-body": "Visualisierungen hinzufügen, die mit anderen Dashboards geteilt werden.",
"add-library-panel-button": "Bibliotheksfenster hinzufügen",
@@ -874,6 +985,9 @@
"import-a-dashboard-header": "Dashboard importieren",
"import-dashboard-button": "Dashboard importieren"
},
+ "errors": {
+ "failed-to-load": ""
+ },
"inspect": {
"data-tab": "Daten",
"error-tab": "Fehler",
@@ -926,19 +1040,130 @@
"rows": "Gesamtanzahl an Zeilen",
"table-title": "Statistiken"
},
+ "options": {
+ "description": "",
+ "title": "",
+ "title-option": ""
+ },
+ "panel-edit": {
+ "alerting-tab": {
+ "dashboard-not-saved": "",
+ "no-rules": ""
+ }
+ },
+ "responsive-layout": {
+ "description": "",
+ "item-options": {
+ "hide-no-data": "",
+ "title": ""
+ },
+ "name": "",
+ "options": {
+ "columns": "",
+ "fixed": "",
+ "min": "",
+ "one-column": "",
+ "rows": "",
+ "three-columns": "",
+ "two-columns": ""
+ }
+ },
+ "rows-layout": {
+ "description": "",
+ "name": "",
+ "row": {
+ "collapse": "",
+ "expand": "",
+ "new": "",
+ "repeat": {
+ "learn-more": "",
+ "warning": ""
+ }
+ },
+ "row-options": {
+ "height": {
+ "expand": "",
+ "hide-row-header": "",
+ "min": "",
+ "title": ""
+ },
+ "repeat": {
+ "title": "",
+ "variable": {
+ "title": ""
+ }
+ },
+ "title": "",
+ "title-option": ""
+ }
+ },
+ "tabs-layout": {
+ "description": "",
+ "name": "",
+ "tab": {
+ "new": ""
+ },
+ "tab-options": {
+ "title": "",
+ "title-option": ""
+ }
+ },
"toolbar": {
"add": "Hinzufügen",
+ "add-panel": "",
+ "add-panel-lib": "",
+ "add-row": "",
+ "add-tab": "",
"alert-rules": "Warnregeln",
+ "back-to-dashboard": "",
+ "dashboard-settings": {
+ "label": "",
+ "tooltip": ""
+ },
+ "discard-library-panel-changes": "",
+ "discard-panel": "",
+ "discard-panel-new": "",
+ "edit": {
+ "label": "",
+ "tooltip": ""
+ },
+ "edit-dashboard-v2-schema": "",
+ "enter-edit-mode": {
+ "label": "",
+ "tooltip": ""
+ },
+ "exit-edit-mode": {
+ "label": "",
+ "tooltip": ""
+ },
"mark-favorite": "Als Favorit markieren",
+ "more-save-options": "",
"open-original": "Original-Dashboard öffnen",
"playlist-next": "Zum nächsten Dashboard",
"playlist-previous": "Zum vorherigen Dashboard",
"playlist-stop": "Wiedergabeliste stoppen",
+ "public-dashboard": "",
"refresh": "Dashboard aktualisieren",
"save": "Dashboard speichern",
+ "save-dashboard": {
+ "label": "",
+ "tooltip": ""
+ },
+ "save-dashboard-copy": {
+ "label": "",
+ "tooltip": ""
+ },
+ "save-dashboard-short": "",
+ "save-library-panel": "",
"settings": "Dashboard-Einstellungen",
- "share": "Dashboard oder Panel teilen",
+ "share": {
+ "label": "",
+ "tooltip": ""
+ },
"share-button": "Teilen",
+ "show-hidden-elements": "",
+ "switch-old-dashboard": "",
+ "unlink-library-panel": "",
"unmark-favorite": "Markierung als Favorit entfernen"
},
"validation": {
@@ -946,6 +1171,14 @@
"invalid-json": "JSON ungültig",
"tags-expected-array": "Array der jeweiligen Tags",
"tags-expected-strings": "String-Array der jeweiligen Tags"
+ },
+ "viz-panel": {
+ "options": {
+ "description": "",
+ "title": "",
+ "title-option": "",
+ "transparent-background": ""
+ }
}
},
"dashboard-import": {
@@ -1048,6 +1281,12 @@
"angular-deprecation-description": "",
"angular-deprecation-heading": ""
},
+ "panel-queries": {
+ "add-query-from-library": ""
+ },
+ "query-library": {
+ "add-query-button": ""
+ },
"settings": {
"variables": {
"dependencies": {
@@ -1125,40 +1364,11 @@
"scan-for-older-logs": "",
"stop-scan": ""
},
- "query-library": {
- "add-edit-description": "",
- "cancel": "",
- "default-description": "",
- "delete-query": "",
- "delete-query-text": "",
- "delete-query-title": "",
- "private": "",
- "public": "",
- "query-deleted": "",
- "query-template-add-error": "",
- "query-template-added": "",
- "query-template-edit-error": "",
- "query-template-edited": "",
- "save": ""
- },
- "query-template-modal": {
- "add-info": "",
- "add-title": "",
- "auto-star": "",
- "data-source-name": "",
- "description": "",
- "edit-info": "",
- "edit-title": "",
- "query": "",
- "visibility": ""
- },
- "query-template-modall": {
- "data-source-type": ""
- },
"rich-history": {
"close-tooltip": "Abfrageverlauf schließen",
"datasource-a-z": "Datenquelle A-Z",
"datasource-z-a": "Datenquelle Z-A",
+ "library-history-dropdown": "",
"newest-first": "Neueste zuerst",
"oldest-first": "Älteste zuerst",
"query-history": "Abfrageverlauf",
@@ -1305,10 +1515,9 @@
"sortBy": ""
},
"related-logs": {
- "docsLink": "",
+ "LrrDocsLink": "",
"openExploreLogs": "",
- "relatedLogsUnavailableAfterDocsLink": "",
- "relatedLogsUnavailableBeforeDocsLink": "",
+ "relatedLogsUnavailable": "",
"warnExperimentalFeature": ""
},
"viewBy": ""
@@ -1357,6 +1566,24 @@
"send-custom-feedback": ""
},
"grafana-ui": {
+ "action-editor": {
+ "button": {
+ "confirm": "",
+ "confirm-action": ""
+ },
+ "inline": {
+ "add-action": "",
+ "edit-action": ""
+ },
+ "modal": {
+ "action-body": "",
+ "action-method": "",
+ "action-query-params": "",
+ "action-title": "",
+ "action-title-placeholder": "",
+ "one-click-description": ""
+ }
+ },
"auto-save-field": {
"saved": "",
"saving": ""
@@ -1373,11 +1600,21 @@
},
"data-link-editor-modal": {
"cancel": "",
+ "one-click-description": "",
"save": ""
},
+ "data-link-inline-editor": {
+ "one-click": ""
+ },
"data-links-inline-editor": {
"add-link": "",
- "one-click-link": ""
+ "edit-link": "",
+ "one-click": "",
+ "one-click-enabled": "",
+ "title-not-provided": "",
+ "tooltip-edit": "",
+ "tooltip-remove": "",
+ "url-not-provided": ""
},
"data-source-http-settings": {
"access-help": "",
@@ -1458,7 +1695,12 @@
"right-axis-indicator": ""
},
"viz-tooltip": {
- "footer-add-annotation": ""
+ "actions-confirmation-input-placeholder": "",
+ "actions-confirmation-label": "",
+ "actions-confirmation-message": "",
+ "footer-add-annotation": "",
+ "footer-click-to-action": "",
+ "footer-click-to-navigate": ""
}
},
"graph": {
@@ -1780,7 +2022,8 @@
},
"log-row-message": {
"ellipsis": "",
- "more": ""
+ "more": "",
+ "see-details": ""
},
"log-rows": {
"disable-popover": {
@@ -1792,6 +2035,13 @@
"shortcut": ""
}
},
+ "logs-navigation": {
+ "newer-logs": "",
+ "older-logs": "",
+ "scroll-bottom": "",
+ "scroll-top": "",
+ "start-of-range": ""
+ },
"popover-menu": {
"copy": "",
"disable-menu": "",
@@ -1881,6 +2131,7 @@
"title": ""
},
"migrated-counts": {
+ "alert_rule_groups": "",
"alert_rules": "",
"contact_points": "",
"dashboards": "",
@@ -1941,9 +2192,8 @@
"public-preview": {
"button-text": "",
"message": "",
- "message-plugins": "",
- "title": "",
- "title-plugins": ""
+ "message-cloud": "",
+ "title": ""
},
"resource-details": {
"dismiss-button": "",
@@ -1986,6 +2236,7 @@
},
"resource-type": {
"alert_rule": "",
+ "alert_rule_group": "",
"contact_point": "",
"dashboard": "Dashboard",
"datasource": "Datenquelle",
@@ -2031,6 +2282,15 @@
"title": "Warum mit Grafana hosten?"
}
},
+ "multicombobox": {
+ "all": {
+ "title": "",
+ "title-filtered": ""
+ },
+ "clear": {
+ "title": ""
+ }
+ },
"nav": {
"add-new-connections": {
"title": "Neue Verbindung hinzufügen"
@@ -2384,7 +2644,8 @@
"list-label": "Navigation",
"open": "",
"undock": "Menü abdocken"
- }
+ },
+ "rss-button": ""
},
"news": {
"drawer": {
@@ -2516,19 +2777,35 @@
}
},
"details": {
+ "connections-tab": {
+ "description": ""
+ },
"labels": {
"contactGrafanaLabs": "",
+ "customLinks": "",
+ "customLinksTooltip": "",
"dependencies": "",
+ "documentation": "",
"downloads": "",
"from": "",
"installedVersion": "",
"lastCommitDate": "",
"latestVersion": "",
- "links": "",
+ "license": "",
+ "raiseAnIssue": "",
"reportAbuse": "",
+ "reportAbuseTooltip": "",
+ "repository": "",
"signature": "",
"status": "",
"updatedAt": ""
+ },
+ "modal": {
+ "cancel": "",
+ "copyEmail": "",
+ "description": "",
+ "node": "",
+ "title": ""
}
},
"empty-state": {
@@ -2743,14 +3020,6 @@
"role-label": "Rolle"
}
},
- "query-library": {
- "datasource-names": "",
- "delete-query-button": "",
- "query-template-get-error": "",
- "search": "",
- "user-info-get-error": "",
- "user-names": ""
- },
"query-operation": {
"header": {
"collapse-row": "Abfragezeile einklappen",
@@ -2760,7 +3029,6 @@
"expand-row": "Suchzeile erweitern",
"hide-response": "",
"remove-query": "Abfrage entfernen",
- "save-to-query-library": "",
"show-response": "",
"toggle-edit-mode": "Textbearbeitungsmodus umschalten"
},
@@ -2862,6 +3130,10 @@
"title": ""
},
"save-dashboards": {
+ "message-length": {
+ "info": "",
+ "title": ""
+ },
"name-exists": {
"message-info": "",
"message-suggestion": "",
@@ -3145,6 +3417,7 @@
"home-dashboard-placeholder": "Standard-Dashboard",
"locale-label": "Sprache",
"locale-placeholder": "Sprache wählen",
+ "theme-description": "",
"theme-label": "UI-Design",
"week-start-label": "Wochenbeginn"
},
@@ -3216,6 +3489,13 @@
"url-column-header": "Snapshot url",
"view-button": "Anzeigen"
},
+ "table": {
+ "container": {
+ "content": "",
+ "show-all-series": "",
+ "show-only-series": ""
+ }
+ },
"tag-filter": {
"clear-button": "",
"loading": "Wird geladen ...",
@@ -3309,20 +3589,26 @@
"start-your-metrics-exploration": "",
"subtitle": ""
},
- "metric-overview": {
- "description-label": "",
- "labels": "",
- "metric-attributes": "",
- "no-description": "",
- "type-label": "",
- "unit-label": "",
- "unknown-type": ""
- },
"metric-select": {
"filter-by": "",
+ "native-histogram": "",
"new-badge": "",
"otel-switch": ""
},
+ "native-histogram-banner": {
+ "ch-heatmap": "",
+ "ch-histogram": "",
+ "click-histogram": "",
+ "hide-examples": "",
+ "learn-more": "",
+ "metric-examples": "",
+ "nh-heatmap": "",
+ "nh-histogram": "",
+ "now": "",
+ "previously": "",
+ "see-examples": "",
+ "sentence": ""
+ },
"recent-metrics": {
"or-view-a-recent-exploration": ""
},
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 2c90bf29511..27301c8765b 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -1699,6 +1699,7 @@
"actions-confirmation-label": "Confirmation message",
"actions-confirmation-message": "Provide a descriptive prompt to confirm or cancel the action.",
"footer-add-annotation": "Add annotation",
+ "footer-click-to-action": "Click to {{actionTitle}}",
"footer-click-to-navigate": "Click to open {{linkTitle}}"
}
},
@@ -2781,17 +2782,30 @@
},
"labels": {
"contactGrafanaLabs": "Contact Grafana Labs",
+ "customLinks": "Custom links ",
+ "customLinksTooltip": "These links are provided by the plugin developer to offer additional, developer-specific resources and information",
"dependencies": "Dependencies",
+ "documentation": "Documentation",
"downloads": "Downloads",
"from": "From",
"installedVersion": "Installed Version",
"lastCommitDate": "Last commit date:",
"latestVersion": "Latest Version",
- "links": "Links ",
+ "license": "License",
+ "raiseAnIssue": "Raise an issue",
"reportAbuse": "Report a concern ",
+ "reportAbuseTooltip": "Report issues related to malicious or harmful plugins directly to Grafana Labs.",
+ "repository": "Repository",
"signature": "Signature",
"status": "Status",
"updatedAt": "Last updated:"
+ },
+ "modal": {
+ "cancel": "Cancel",
+ "copyEmail": "Copy email address",
+ "description": "This feature is for reporting malicious or harmful behaviour within plugins. For plugin concerns, email us at: ",
+ "node": "Note: For general plugin issues like bugs or feature requests, please contact the plugin author using the provided links. ",
+ "title": "Report a plugin concern"
}
},
"empty-state": {
diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json
index f324380f834..02db11b3d08 100644
--- a/public/locales/es-ES/grafana.json
+++ b/public/locales/es-ES/grafana.json
@@ -34,12 +34,6 @@
"save-button": ""
}
},
- "actions-editor": {
- "inline": {
- "add-button": "",
- "one-click-link": ""
- }
- },
"admin": {
"anon-users": {
"not-found": ""
@@ -174,6 +168,24 @@
}
},
"alerting": {
+ "alert": {
+ "alert-state": "",
+ "annotations": "",
+ "evaluation": "",
+ "evaluation-paused": "",
+ "evaluation-paused-description": "",
+ "last-evaluated": "",
+ "last-evaluation-duration": "",
+ "last-updated-at": "",
+ "last-updated-by": "",
+ "no-annotations": "",
+ "pending-period": "",
+ "rule": "",
+ "rule-identifier": "",
+ "rule-type": "",
+ "state-error-timeout": "",
+ "state-no-data": ""
+ },
"alert-recording-rule-form": {
"evaluation-behaviour": {
"description": {
@@ -283,6 +295,7 @@
"contactPointFilter": {
"label": ""
},
+ "copy-to-clipboard": "",
"export": {
"subtitle": {
"formats": "",
@@ -299,6 +312,13 @@
}
}
},
+ "group-actions": {
+ "actions-trigger": "",
+ "delete": "",
+ "edit": "",
+ "export": "",
+ "reorder": ""
+ },
"list-view": {
"empty": {
"new-alert-rule": "",
@@ -484,11 +504,23 @@
},
"rule-list": {
"configure-datasource": "",
+ "ds-error-boundary": {
+ "description": "",
+ "title": ""
+ },
"filter-view": {
"no-more-results": "",
"no-rules-found": ""
},
- "new-alert-rule": ""
+ "new-alert-rule": "",
+ "pagination": {
+ "next-page": "",
+ "previous-page": ""
+ },
+ "return-button": {
+ "title": ""
+ },
+ "rulerrule-loading-error": ""
},
"rule-state": {
"creating": "",
@@ -690,7 +722,10 @@
"title": ""
},
"custom-value": {
- "label": ""
+ "description": ""
+ },
+ "group": {
+ "undefined": ""
},
"options": {
"no-found": ""
@@ -863,6 +898,82 @@
"redirect-link": "Lista en Alertas de Grafana",
"subtitle": "Reglas de alerta relacionadas con este tablero"
},
+ "default-layout": {
+ "description": "",
+ "item-options": {
+ "repeat": {
+ "direction": {
+ "horizontal": "",
+ "title": "",
+ "vertical": ""
+ },
+ "max": "",
+ "title": "",
+ "variable": {
+ "description": "",
+ "title": ""
+ }
+ }
+ },
+ "name": "",
+ "row-actions": {
+ "delete": "",
+ "modal": {
+ "alt-action": "",
+ "text": "",
+ "title": ""
+ }
+ },
+ "row-options": {
+ "button": {
+ "label": ""
+ },
+ "form": {
+ "cancel": "",
+ "repeat-for": {
+ "label": "",
+ "learn-more": "",
+ "warning": {
+ "text": ""
+ }
+ },
+ "title": "",
+ "update": ""
+ },
+ "modal": {
+ "title": ""
+ }
+ }
+ },
+ "edit-pane": {
+ "objects": {
+ "multi-select": {
+ "selection-number": ""
+ }
+ },
+ "open": "",
+ "panels": {
+ "multi-select": {
+ "selection-number": ""
+ }
+ },
+ "row": {
+ "header": {
+ "hide": "",
+ "title": ""
+ },
+ "multi-select": {
+ "options-header": "",
+ "selection-number": ""
+ }
+ },
+ "tab": {
+ "multi-select": {
+ "options-header": "",
+ "selection-number": ""
+ }
+ }
+ },
"empty": {
"add-library-panel-body": "Añadir las visualizaciones que se comparten con otros tableros.",
"add-library-panel-button": "Añadir panel de biblioteca",
@@ -874,6 +985,9 @@
"import-a-dashboard-header": "Importar un tablero",
"import-dashboard-button": "Importar panel de control"
},
+ "errors": {
+ "failed-to-load": ""
+ },
"inspect": {
"data-tab": "Datos",
"error-tab": "Error",
@@ -926,19 +1040,130 @@
"rows": "Número total de filas",
"table-title": "Estadísticas"
},
+ "options": {
+ "description": "",
+ "title": "",
+ "title-option": ""
+ },
+ "panel-edit": {
+ "alerting-tab": {
+ "dashboard-not-saved": "",
+ "no-rules": ""
+ }
+ },
+ "responsive-layout": {
+ "description": "",
+ "item-options": {
+ "hide-no-data": "",
+ "title": ""
+ },
+ "name": "",
+ "options": {
+ "columns": "",
+ "fixed": "",
+ "min": "",
+ "one-column": "",
+ "rows": "",
+ "three-columns": "",
+ "two-columns": ""
+ }
+ },
+ "rows-layout": {
+ "description": "",
+ "name": "",
+ "row": {
+ "collapse": "",
+ "expand": "",
+ "new": "",
+ "repeat": {
+ "learn-more": "",
+ "warning": ""
+ }
+ },
+ "row-options": {
+ "height": {
+ "expand": "",
+ "hide-row-header": "",
+ "min": "",
+ "title": ""
+ },
+ "repeat": {
+ "title": "",
+ "variable": {
+ "title": ""
+ }
+ },
+ "title": "",
+ "title-option": ""
+ }
+ },
+ "tabs-layout": {
+ "description": "",
+ "name": "",
+ "tab": {
+ "new": ""
+ },
+ "tab-options": {
+ "title": "",
+ "title-option": ""
+ }
+ },
"toolbar": {
"add": "Añadir",
+ "add-panel": "",
+ "add-panel-lib": "",
+ "add-row": "",
+ "add-tab": "",
"alert-rules": "Reglas de alerta",
+ "back-to-dashboard": "",
+ "dashboard-settings": {
+ "label": "",
+ "tooltip": ""
+ },
+ "discard-library-panel-changes": "",
+ "discard-panel": "",
+ "discard-panel-new": "",
+ "edit": {
+ "label": "",
+ "tooltip": ""
+ },
+ "edit-dashboard-v2-schema": "",
+ "enter-edit-mode": {
+ "label": "",
+ "tooltip": ""
+ },
+ "exit-edit-mode": {
+ "label": "",
+ "tooltip": ""
+ },
"mark-favorite": "Marcar como favorito",
+ "more-save-options": "",
"open-original": "Abrir el panel de control original",
"playlist-next": "Ir al siguiente panel de control",
"playlist-previous": "Ir al panel de control anterior",
"playlist-stop": "Detener la lista de reproducción",
+ "public-dashboard": "",
"refresh": "Actualizar panel de control",
"save": "Guardar panel de control",
+ "save-dashboard": {
+ "label": "",
+ "tooltip": ""
+ },
+ "save-dashboard-copy": {
+ "label": "",
+ "tooltip": ""
+ },
+ "save-dashboard-short": "",
+ "save-library-panel": "",
"settings": "Ajustes del panel de control",
- "share": "Compartir panel o panel de control",
+ "share": {
+ "label": "",
+ "tooltip": ""
+ },
"share-button": "Compartir",
+ "show-hidden-elements": "",
+ "switch-old-dashboard": "",
+ "unlink-library-panel": "",
"unmark-favorite": "Deshacer marca como favorito"
},
"validation": {
@@ -946,6 +1171,14 @@
"invalid-json": "JSON no válido",
"tags-expected-array": "etiquetas: matriz prevista",
"tags-expected-strings": "etiquetas: matriz de cadenas prevista"
+ },
+ "viz-panel": {
+ "options": {
+ "description": "",
+ "title": "",
+ "title-option": "",
+ "transparent-background": ""
+ }
}
},
"dashboard-import": {
@@ -1048,6 +1281,12 @@
"angular-deprecation-description": "",
"angular-deprecation-heading": ""
},
+ "panel-queries": {
+ "add-query-from-library": ""
+ },
+ "query-library": {
+ "add-query-button": ""
+ },
"settings": {
"variables": {
"dependencies": {
@@ -1125,40 +1364,11 @@
"scan-for-older-logs": "",
"stop-scan": ""
},
- "query-library": {
- "add-edit-description": "",
- "cancel": "",
- "default-description": "",
- "delete-query": "",
- "delete-query-text": "",
- "delete-query-title": "",
- "private": "",
- "public": "",
- "query-deleted": "",
- "query-template-add-error": "",
- "query-template-added": "",
- "query-template-edit-error": "",
- "query-template-edited": "",
- "save": ""
- },
- "query-template-modal": {
- "add-info": "",
- "add-title": "",
- "auto-star": "",
- "data-source-name": "",
- "description": "",
- "edit-info": "",
- "edit-title": "",
- "query": "",
- "visibility": ""
- },
- "query-template-modall": {
- "data-source-type": ""
- },
"rich-history": {
"close-tooltip": "Cerrar el historial de consultas",
"datasource-a-z": "Fuente de datos A-Z",
"datasource-z-a": "Fuente de datos Z-A",
+ "library-history-dropdown": "",
"newest-first": "El más reciente primero",
"oldest-first": "El más antiguo primero",
"query-history": "Historial de consultas",
@@ -1305,10 +1515,9 @@
"sortBy": ""
},
"related-logs": {
- "docsLink": "",
+ "LrrDocsLink": "",
"openExploreLogs": "",
- "relatedLogsUnavailableAfterDocsLink": "",
- "relatedLogsUnavailableBeforeDocsLink": "",
+ "relatedLogsUnavailable": "",
"warnExperimentalFeature": ""
},
"viewBy": ""
@@ -1357,6 +1566,24 @@
"send-custom-feedback": ""
},
"grafana-ui": {
+ "action-editor": {
+ "button": {
+ "confirm": "",
+ "confirm-action": ""
+ },
+ "inline": {
+ "add-action": "",
+ "edit-action": ""
+ },
+ "modal": {
+ "action-body": "",
+ "action-method": "",
+ "action-query-params": "",
+ "action-title": "",
+ "action-title-placeholder": "",
+ "one-click-description": ""
+ }
+ },
"auto-save-field": {
"saved": "",
"saving": ""
@@ -1373,11 +1600,21 @@
},
"data-link-editor-modal": {
"cancel": "",
+ "one-click-description": "",
"save": ""
},
+ "data-link-inline-editor": {
+ "one-click": ""
+ },
"data-links-inline-editor": {
"add-link": "",
- "one-click-link": ""
+ "edit-link": "",
+ "one-click": "",
+ "one-click-enabled": "",
+ "title-not-provided": "",
+ "tooltip-edit": "",
+ "tooltip-remove": "",
+ "url-not-provided": ""
},
"data-source-http-settings": {
"access-help": "",
@@ -1458,7 +1695,12 @@
"right-axis-indicator": ""
},
"viz-tooltip": {
- "footer-add-annotation": ""
+ "actions-confirmation-input-placeholder": "",
+ "actions-confirmation-label": "",
+ "actions-confirmation-message": "",
+ "footer-add-annotation": "",
+ "footer-click-to-action": "",
+ "footer-click-to-navigate": ""
}
},
"graph": {
@@ -1780,7 +2022,8 @@
},
"log-row-message": {
"ellipsis": "",
- "more": ""
+ "more": "",
+ "see-details": ""
},
"log-rows": {
"disable-popover": {
@@ -1792,6 +2035,13 @@
"shortcut": ""
}
},
+ "logs-navigation": {
+ "newer-logs": "",
+ "older-logs": "",
+ "scroll-bottom": "",
+ "scroll-top": "",
+ "start-of-range": ""
+ },
"popover-menu": {
"copy": "",
"disable-menu": "",
@@ -1881,6 +2131,7 @@
"title": ""
},
"migrated-counts": {
+ "alert_rule_groups": "",
"alert_rules": "",
"contact_points": "",
"dashboards": "",
@@ -1941,9 +2192,8 @@
"public-preview": {
"button-text": "",
"message": "",
- "message-plugins": "",
- "title": "",
- "title-plugins": ""
+ "message-cloud": "",
+ "title": ""
},
"resource-details": {
"dismiss-button": "",
@@ -1986,6 +2236,7 @@
},
"resource-type": {
"alert_rule": "",
+ "alert_rule_group": "",
"contact_point": "",
"dashboard": "Panel de control",
"datasource": "Fuente de datos",
@@ -2031,6 +2282,15 @@
"title": "¿Por qué alojar con Grafana?"
}
},
+ "multicombobox": {
+ "all": {
+ "title": "",
+ "title-filtered": ""
+ },
+ "clear": {
+ "title": ""
+ }
+ },
"nav": {
"add-new-connections": {
"title": "Añadir nueva conexión"
@@ -2384,7 +2644,8 @@
"list-label": "Navegación",
"open": "",
"undock": "Desanclar el menú"
- }
+ },
+ "rss-button": ""
},
"news": {
"drawer": {
@@ -2516,19 +2777,35 @@
}
},
"details": {
+ "connections-tab": {
+ "description": ""
+ },
"labels": {
"contactGrafanaLabs": "",
+ "customLinks": "",
+ "customLinksTooltip": "",
"dependencies": "",
+ "documentation": "",
"downloads": "",
"from": "",
"installedVersion": "",
"lastCommitDate": "",
"latestVersion": "",
- "links": "",
+ "license": "",
+ "raiseAnIssue": "",
"reportAbuse": "",
+ "reportAbuseTooltip": "",
+ "repository": "",
"signature": "",
"status": "",
"updatedAt": ""
+ },
+ "modal": {
+ "cancel": "",
+ "copyEmail": "",
+ "description": "",
+ "node": "",
+ "title": ""
}
},
"empty-state": {
@@ -2743,14 +3020,6 @@
"role-label": "Rol"
}
},
- "query-library": {
- "datasource-names": "",
- "delete-query-button": "",
- "query-template-get-error": "",
- "search": "",
- "user-info-get-error": "",
- "user-names": ""
- },
"query-operation": {
"header": {
"collapse-row": "Contraer la fila de la consulta",
@@ -2760,7 +3029,6 @@
"expand-row": "Expandir la fila de la consulta",
"hide-response": "",
"remove-query": "Eliminar consulta",
- "save-to-query-library": "",
"show-response": "",
"toggle-edit-mode": "Alternar el modo de edición de texto"
},
@@ -2862,6 +3130,10 @@
"title": ""
},
"save-dashboards": {
+ "message-length": {
+ "info": "",
+ "title": ""
+ },
"name-exists": {
"message-info": "",
"message-suggestion": "",
@@ -3145,6 +3417,7 @@
"home-dashboard-placeholder": "Panel de control por defecto",
"locale-label": "Idioma",
"locale-placeholder": "Cambiar idioma",
+ "theme-description": "",
"theme-label": "Tema de interfaz de usuario",
"week-start-label": "Inicio de la semana"
},
@@ -3216,6 +3489,13 @@
"url-column-header": "URL de la instantánea",
"view-button": "Vista"
},
+ "table": {
+ "container": {
+ "content": "",
+ "show-all-series": "",
+ "show-only-series": ""
+ }
+ },
"tag-filter": {
"clear-button": "",
"loading": "Cargando...",
@@ -3309,20 +3589,26 @@
"start-your-metrics-exploration": "",
"subtitle": ""
},
- "metric-overview": {
- "description-label": "",
- "labels": "",
- "metric-attributes": "",
- "no-description": "",
- "type-label": "",
- "unit-label": "",
- "unknown-type": ""
- },
"metric-select": {
"filter-by": "",
+ "native-histogram": "",
"new-badge": "",
"otel-switch": ""
},
+ "native-histogram-banner": {
+ "ch-heatmap": "",
+ "ch-histogram": "",
+ "click-histogram": "",
+ "hide-examples": "",
+ "learn-more": "",
+ "metric-examples": "",
+ "nh-heatmap": "",
+ "nh-histogram": "",
+ "now": "",
+ "previously": "",
+ "see-examples": "",
+ "sentence": ""
+ },
"recent-metrics": {
"or-view-a-recent-exploration": ""
},
diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json
index 2ea9883ffe1..33cfe2d4b40 100644
--- a/public/locales/fr-FR/grafana.json
+++ b/public/locales/fr-FR/grafana.json
@@ -34,12 +34,6 @@
"save-button": ""
}
},
- "actions-editor": {
- "inline": {
- "add-button": "",
- "one-click-link": ""
- }
- },
"admin": {
"anon-users": {
"not-found": ""
@@ -174,6 +168,24 @@
}
},
"alerting": {
+ "alert": {
+ "alert-state": "",
+ "annotations": "",
+ "evaluation": "",
+ "evaluation-paused": "",
+ "evaluation-paused-description": "",
+ "last-evaluated": "",
+ "last-evaluation-duration": "",
+ "last-updated-at": "",
+ "last-updated-by": "",
+ "no-annotations": "",
+ "pending-period": "",
+ "rule": "",
+ "rule-identifier": "",
+ "rule-type": "",
+ "state-error-timeout": "",
+ "state-no-data": ""
+ },
"alert-recording-rule-form": {
"evaluation-behaviour": {
"description": {
@@ -283,6 +295,7 @@
"contactPointFilter": {
"label": ""
},
+ "copy-to-clipboard": "",
"export": {
"subtitle": {
"formats": "",
@@ -299,6 +312,13 @@
}
}
},
+ "group-actions": {
+ "actions-trigger": "",
+ "delete": "",
+ "edit": "",
+ "export": "",
+ "reorder": ""
+ },
"list-view": {
"empty": {
"new-alert-rule": "",
@@ -484,11 +504,23 @@
},
"rule-list": {
"configure-datasource": "",
+ "ds-error-boundary": {
+ "description": "",
+ "title": ""
+ },
"filter-view": {
"no-more-results": "",
"no-rules-found": ""
},
- "new-alert-rule": ""
+ "new-alert-rule": "",
+ "pagination": {
+ "next-page": "",
+ "previous-page": ""
+ },
+ "return-button": {
+ "title": ""
+ },
+ "rulerrule-loading-error": ""
},
"rule-state": {
"creating": "",
@@ -690,7 +722,10 @@
"title": ""
},
"custom-value": {
- "label": ""
+ "description": ""
+ },
+ "group": {
+ "undefined": ""
},
"options": {
"no-found": ""
@@ -863,6 +898,82 @@
"redirect-link": "Liste dans Alertes Grafana",
"subtitle": "Règles d'alerte liées à ce tableau de bord"
},
+ "default-layout": {
+ "description": "",
+ "item-options": {
+ "repeat": {
+ "direction": {
+ "horizontal": "",
+ "title": "",
+ "vertical": ""
+ },
+ "max": "",
+ "title": "",
+ "variable": {
+ "description": "",
+ "title": ""
+ }
+ }
+ },
+ "name": "",
+ "row-actions": {
+ "delete": "",
+ "modal": {
+ "alt-action": "",
+ "text": "",
+ "title": ""
+ }
+ },
+ "row-options": {
+ "button": {
+ "label": ""
+ },
+ "form": {
+ "cancel": "",
+ "repeat-for": {
+ "label": "",
+ "learn-more": "",
+ "warning": {
+ "text": ""
+ }
+ },
+ "title": "",
+ "update": ""
+ },
+ "modal": {
+ "title": ""
+ }
+ }
+ },
+ "edit-pane": {
+ "objects": {
+ "multi-select": {
+ "selection-number": ""
+ }
+ },
+ "open": "",
+ "panels": {
+ "multi-select": {
+ "selection-number": ""
+ }
+ },
+ "row": {
+ "header": {
+ "hide": "",
+ "title": ""
+ },
+ "multi-select": {
+ "options-header": "",
+ "selection-number": ""
+ }
+ },
+ "tab": {
+ "multi-select": {
+ "options-header": "",
+ "selection-number": ""
+ }
+ }
+ },
"empty": {
"add-library-panel-body": "Ajoutez des visualisations partagées avec d'autres tableaux de bord.",
"add-library-panel-button": "Ajouter un panneau Bibliothèque",
@@ -874,6 +985,9 @@
"import-a-dashboard-header": "Importer un tableau de bord",
"import-dashboard-button": "Importer un tableau de bord"
},
+ "errors": {
+ "failed-to-load": ""
+ },
"inspect": {
"data-tab": "Données",
"error-tab": "Erreur",
@@ -926,19 +1040,130 @@
"rows": "Nombre total de lignes",
"table-title": "Statistiques"
},
+ "options": {
+ "description": "",
+ "title": "",
+ "title-option": ""
+ },
+ "panel-edit": {
+ "alerting-tab": {
+ "dashboard-not-saved": "",
+ "no-rules": ""
+ }
+ },
+ "responsive-layout": {
+ "description": "",
+ "item-options": {
+ "hide-no-data": "",
+ "title": ""
+ },
+ "name": "",
+ "options": {
+ "columns": "",
+ "fixed": "",
+ "min": "",
+ "one-column": "",
+ "rows": "",
+ "three-columns": "",
+ "two-columns": ""
+ }
+ },
+ "rows-layout": {
+ "description": "",
+ "name": "",
+ "row": {
+ "collapse": "",
+ "expand": "",
+ "new": "",
+ "repeat": {
+ "learn-more": "",
+ "warning": ""
+ }
+ },
+ "row-options": {
+ "height": {
+ "expand": "",
+ "hide-row-header": "",
+ "min": "",
+ "title": ""
+ },
+ "repeat": {
+ "title": "",
+ "variable": {
+ "title": ""
+ }
+ },
+ "title": "",
+ "title-option": ""
+ }
+ },
+ "tabs-layout": {
+ "description": "",
+ "name": "",
+ "tab": {
+ "new": ""
+ },
+ "tab-options": {
+ "title": "",
+ "title-option": ""
+ }
+ },
"toolbar": {
"add": "Ajouter",
+ "add-panel": "",
+ "add-panel-lib": "",
+ "add-row": "",
+ "add-tab": "",
"alert-rules": "Règles d'alerte",
+ "back-to-dashboard": "",
+ "dashboard-settings": {
+ "label": "",
+ "tooltip": ""
+ },
+ "discard-library-panel-changes": "",
+ "discard-panel": "",
+ "discard-panel-new": "",
+ "edit": {
+ "label": "",
+ "tooltip": ""
+ },
+ "edit-dashboard-v2-schema": "",
+ "enter-edit-mode": {
+ "label": "",
+ "tooltip": ""
+ },
+ "exit-edit-mode": {
+ "label": "",
+ "tooltip": ""
+ },
"mark-favorite": "Marquer comme favori",
+ "more-save-options": "",
"open-original": "Ouvrir le tableau de bord d'origine",
"playlist-next": "Accéder au tableau de bord suivant",
"playlist-previous": "Accéder au tableau de bord précédent",
"playlist-stop": "Arrêter la liste de lecture",
+ "public-dashboard": "",
"refresh": "Actualiser le tableau de bord",
"save": "Enregistrer le tableau de bord",
+ "save-dashboard": {
+ "label": "",
+ "tooltip": ""
+ },
+ "save-dashboard-copy": {
+ "label": "",
+ "tooltip": ""
+ },
+ "save-dashboard-short": "",
+ "save-library-panel": "",
"settings": "Paramètres du tableau de bord",
- "share": "Partager le tableau de bord ou le panneau",
+ "share": {
+ "label": "",
+ "tooltip": ""
+ },
"share-button": "Partager",
+ "show-hidden-elements": "",
+ "switch-old-dashboard": "",
+ "unlink-library-panel": "",
"unmark-favorite": "Supprimer des favoris"
},
"validation": {
@@ -946,6 +1171,14 @@
"invalid-json": "JSON non valide",
"tags-expected-array": "étiquettes attendues tableau",
"tags-expected-strings": "étiquettes attendues tableau de chaînes"
+ },
+ "viz-panel": {
+ "options": {
+ "description": "",
+ "title": "",
+ "title-option": "",
+ "transparent-background": ""
+ }
}
},
"dashboard-import": {
@@ -1048,6 +1281,12 @@
"angular-deprecation-description": "",
"angular-deprecation-heading": ""
},
+ "panel-queries": {
+ "add-query-from-library": ""
+ },
+ "query-library": {
+ "add-query-button": ""
+ },
"settings": {
"variables": {
"dependencies": {
@@ -1125,40 +1364,11 @@
"scan-for-older-logs": "",
"stop-scan": ""
},
- "query-library": {
- "add-edit-description": "",
- "cancel": "",
- "default-description": "",
- "delete-query": "",
- "delete-query-text": "",
- "delete-query-title": "",
- "private": "",
- "public": "",
- "query-deleted": "",
- "query-template-add-error": "",
- "query-template-added": "",
- "query-template-edit-error": "",
- "query-template-edited": "",
- "save": ""
- },
- "query-template-modal": {
- "add-info": "",
- "add-title": "",
- "auto-star": "",
- "data-source-name": "",
- "description": "",
- "edit-info": "",
- "edit-title": "",
- "query": "",
- "visibility": ""
- },
- "query-template-modall": {
- "data-source-type": ""
- },
"rich-history": {
"close-tooltip": "Fermer l'historique des requêtes",
"datasource-a-z": "Source de données A-Z",
"datasource-z-a": "Source de données Z-A",
+ "library-history-dropdown": "",
"newest-first": "Plus récent en premier",
"oldest-first": "Plus ancien en premier",
"query-history": "Historique des requêtes",
@@ -1305,10 +1515,9 @@
"sortBy": ""
},
"related-logs": {
- "docsLink": "",
+ "LrrDocsLink": "",
"openExploreLogs": "",
- "relatedLogsUnavailableAfterDocsLink": "",
- "relatedLogsUnavailableBeforeDocsLink": "",
+ "relatedLogsUnavailable": "",
"warnExperimentalFeature": ""
},
"viewBy": ""
@@ -1357,6 +1566,24 @@
"send-custom-feedback": ""
},
"grafana-ui": {
+ "action-editor": {
+ "button": {
+ "confirm": "",
+ "confirm-action": ""
+ },
+ "inline": {
+ "add-action": "",
+ "edit-action": ""
+ },
+ "modal": {
+ "action-body": "",
+ "action-method": "",
+ "action-query-params": "",
+ "action-title": "",
+ "action-title-placeholder": "",
+ "one-click-description": ""
+ }
+ },
"auto-save-field": {
"saved": "",
"saving": ""
@@ -1373,11 +1600,21 @@
},
"data-link-editor-modal": {
"cancel": "",
+ "one-click-description": "",
"save": ""
},
+ "data-link-inline-editor": {
+ "one-click": ""
+ },
"data-links-inline-editor": {
"add-link": "",
- "one-click-link": ""
+ "edit-link": "",
+ "one-click": "",
+ "one-click-enabled": "",
+ "title-not-provided": "",
+ "tooltip-edit": "",
+ "tooltip-remove": "",
+ "url-not-provided": ""
},
"data-source-http-settings": {
"access-help": "",
@@ -1458,7 +1695,12 @@
"right-axis-indicator": ""
},
"viz-tooltip": {
- "footer-add-annotation": ""
+ "actions-confirmation-input-placeholder": "",
+ "actions-confirmation-label": "",
+ "actions-confirmation-message": "",
+ "footer-add-annotation": "",
+ "footer-click-to-action": "",
+ "footer-click-to-navigate": ""
}
},
"graph": {
@@ -1780,7 +2022,8 @@
},
"log-row-message": {
"ellipsis": "",
- "more": ""
+ "more": "",
+ "see-details": ""
},
"log-rows": {
"disable-popover": {
@@ -1792,6 +2035,13 @@
"shortcut": ""
}
},
+ "logs-navigation": {
+ "newer-logs": "",
+ "older-logs": "",
+ "scroll-bottom": "",
+ "scroll-top": "",
+ "start-of-range": ""
+ },
"popover-menu": {
"copy": "",
"disable-menu": "",
@@ -1881,6 +2131,7 @@
"title": ""
},
"migrated-counts": {
+ "alert_rule_groups": "",
"alert_rules": "",
"contact_points": "",
"dashboards": "",
@@ -1941,9 +2192,8 @@
"public-preview": {
"button-text": "",
"message": "",
- "message-plugins": "",
- "title": "",
- "title-plugins": ""
+ "message-cloud": "",
+ "title": ""
},
"resource-details": {
"dismiss-button": "",
@@ -1986,6 +2236,7 @@
},
"resource-type": {
"alert_rule": "",
+ "alert_rule_group": "",
"contact_point": "",
"dashboard": "Tableau de bord",
"datasource": "Source de données",
@@ -2031,6 +2282,15 @@
"title": "Pourquoi héberger avec Grafana ?"
}
},
+ "multicombobox": {
+ "all": {
+ "title": "",
+ "title-filtered": ""
+ },
+ "clear": {
+ "title": ""
+ }
+ },
"nav": {
"add-new-connections": {
"title": "Ajouter une nouvelle connexion"
@@ -2384,7 +2644,8 @@
"list-label": "Navigation",
"open": "",
"undock": "Ancrer le menu"
- }
+ },
+ "rss-button": ""
},
"news": {
"drawer": {
@@ -2516,19 +2777,35 @@
}
},
"details": {
+ "connections-tab": {
+ "description": ""
+ },
"labels": {
"contactGrafanaLabs": "",
+ "customLinks": "",
+ "customLinksTooltip": "",
"dependencies": "",
+ "documentation": "",
"downloads": "",
"from": "",
"installedVersion": "",
"lastCommitDate": "",
"latestVersion": "",
- "links": "",
+ "license": "",
+ "raiseAnIssue": "",
"reportAbuse": "",
+ "reportAbuseTooltip": "",
+ "repository": "",
"signature": "",
"status": "",
"updatedAt": ""
+ },
+ "modal": {
+ "cancel": "",
+ "copyEmail": "",
+ "description": "",
+ "node": "",
+ "title": ""
}
},
"empty-state": {
@@ -2743,14 +3020,6 @@
"role-label": "Rôle"
}
},
- "query-library": {
- "datasource-names": "",
- "delete-query-button": "",
- "query-template-get-error": "",
- "search": "",
- "user-info-get-error": "",
- "user-names": ""
- },
"query-operation": {
"header": {
"collapse-row": "Réduire la ligne de requête",
@@ -2760,7 +3029,6 @@
"expand-row": "Développer la ligne de requête",
"hide-response": "",
"remove-query": "Supprimer la requête",
- "save-to-query-library": "",
"show-response": "",
"toggle-edit-mode": "Activer/désactiver le mode édition de texte"
},
@@ -2862,6 +3130,10 @@
"title": ""
},
"save-dashboards": {
+ "message-length": {
+ "info": "",
+ "title": ""
+ },
"name-exists": {
"message-info": "",
"message-suggestion": "",
@@ -3145,6 +3417,7 @@
"home-dashboard-placeholder": "Tableau de bord par défaut",
"locale-label": "Langue",
"locale-placeholder": "Choisir une langue",
+ "theme-description": "",
"theme-label": "Thème de l'interface utilisateur",
"week-start-label": "Début de la semaine"
},
@@ -3216,6 +3489,13 @@
"url-column-header": "URL de l'instantané",
"view-button": "Afficher"
},
+ "table": {
+ "container": {
+ "content": "",
+ "show-all-series": "",
+ "show-only-series": ""
+ }
+ },
"tag-filter": {
"clear-button": "",
"loading": "Chargement en cours...",
@@ -3309,20 +3589,26 @@
"start-your-metrics-exploration": "",
"subtitle": ""
},
- "metric-overview": {
- "description-label": "",
- "labels": "",
- "metric-attributes": "",
- "no-description": "",
- "type-label": "",
- "unit-label": "",
- "unknown-type": ""
- },
"metric-select": {
"filter-by": "",
+ "native-histogram": "",
"new-badge": "",
"otel-switch": ""
},
+ "native-histogram-banner": {
+ "ch-heatmap": "",
+ "ch-histogram": "",
+ "click-histogram": "",
+ "hide-examples": "",
+ "learn-more": "",
+ "metric-examples": "",
+ "nh-heatmap": "",
+ "nh-histogram": "",
+ "now": "",
+ "previously": "",
+ "see-examples": "",
+ "sentence": ""
+ },
"recent-metrics": {
"or-view-a-recent-exploration": ""
},
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index 61b47f9f9a5..59371299812 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -1699,6 +1699,7 @@
"actions-confirmation-label": "Cőʼnƒįřmäŧįőʼn męşşäģę",
"actions-confirmation-message": "Přővįđę ä đęşčřįpŧįvę přőmpŧ ŧő čőʼnƒįřm őř čäʼnčęľ ŧĥę äčŧįőʼn.",
"footer-add-annotation": "Åđđ äʼnʼnőŧäŧįőʼn",
+ "footer-click-to-action": "Cľįčĸ ŧő {{actionTitle}}",
"footer-click-to-navigate": "Cľįčĸ ŧő őpęʼn {{linkTitle}}"
}
},
@@ -2781,17 +2782,30 @@
},
"labels": {
"contactGrafanaLabs": "Cőʼnŧäčŧ Ğřäƒäʼnä Ŀäþş",
+ "customLinks": "Cūşŧőm ľįʼnĸş ",
+ "customLinksTooltip": "Ŧĥęşę ľįʼnĸş äřę přővįđęđ þy ŧĥę pľūģįʼn đęvęľőpęř ŧő őƒƒęř äđđįŧįőʼnäľ, đęvęľőpęř-şpęčįƒįč řęşőūřčęş äʼnđ įʼnƒőřmäŧįőʼn",
"dependencies": "Đępęʼnđęʼnčįęş",
+ "documentation": "Đőčūmęʼnŧäŧįőʼn",
"downloads": "Đőŵʼnľőäđş",
"from": "Fřőm",
"installedVersion": "Ĩʼnşŧäľľęđ Vęřşįőʼn",
"lastCommitDate": "Ŀäşŧ čőmmįŧ đäŧę:",
"latestVersion": "Ŀäŧęşŧ Vęřşįőʼn",
- "links": "Ŀįʼnĸş ",
+ "license": "Ŀįčęʼnşę",
+ "raiseAnIssue": "Ŗäįşę äʼn įşşūę",
"reportAbuse": "Ŗępőřŧ ä čőʼnčęřʼn ",
+ "reportAbuseTooltip": "Ŗępőřŧ įşşūęş řęľäŧęđ ŧő mäľįčįőūş őř ĥäřmƒūľ pľūģįʼnş đįřęčŧľy ŧő Ğřäƒäʼnä Ŀäþş.",
+ "repository": "Ŗępőşįŧőřy",
"signature": "Ŝįģʼnäŧūřę",
"status": "Ŝŧäŧūş",
"updatedAt": "Ŀäşŧ ūpđäŧęđ:"
+ },
+ "modal": {
+ "cancel": "Cäʼnčęľ",
+ "copyEmail": "Cőpy ęmäįľ äđđřęşş",
+ "description": "Ŧĥįş ƒęäŧūřę įş ƒőř řępőřŧįʼnģ mäľįčįőūş őř ĥäřmƒūľ þęĥävįőūř ŵįŧĥįʼn pľūģįʼnş. Főř pľūģįʼn čőʼnčęřʼnş, ęmäįľ ūş äŧ: ",
+ "node": "Ńőŧę: Főř ģęʼnęřäľ pľūģįʼn įşşūęş ľįĸę þūģş őř ƒęäŧūřę řęqūęşŧş, pľęäşę čőʼnŧäčŧ ŧĥę pľūģįʼn äūŧĥőř ūşįʼnģ ŧĥę přővįđęđ ľįʼnĸş. ",
+ "title": "Ŗępőřŧ ä pľūģįʼn čőʼnčęřʼn"
}
},
"empty-state": {
diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json
index 5e0b7cdc2d4..4e648b574bc 100644
--- a/public/locales/pt-BR/grafana.json
+++ b/public/locales/pt-BR/grafana.json
@@ -34,12 +34,6 @@
"save-button": ""
}
},
- "actions-editor": {
- "inline": {
- "add-button": "",
- "one-click-link": ""
- }
- },
"admin": {
"anon-users": {
"not-found": ""
@@ -174,6 +168,24 @@
}
},
"alerting": {
+ "alert": {
+ "alert-state": "",
+ "annotations": "",
+ "evaluation": "",
+ "evaluation-paused": "",
+ "evaluation-paused-description": "",
+ "last-evaluated": "",
+ "last-evaluation-duration": "",
+ "last-updated-at": "",
+ "last-updated-by": "",
+ "no-annotations": "",
+ "pending-period": "",
+ "rule": "",
+ "rule-identifier": "",
+ "rule-type": "",
+ "state-error-timeout": "",
+ "state-no-data": ""
+ },
"alert-recording-rule-form": {
"evaluation-behaviour": {
"description": {
@@ -283,6 +295,7 @@
"contactPointFilter": {
"label": ""
},
+ "copy-to-clipboard": "",
"export": {
"subtitle": {
"formats": "",
@@ -299,6 +312,13 @@
}
}
},
+ "group-actions": {
+ "actions-trigger": "",
+ "delete": "",
+ "edit": "",
+ "export": "",
+ "reorder": ""
+ },
"list-view": {
"empty": {
"new-alert-rule": "",
@@ -484,11 +504,23 @@
},
"rule-list": {
"configure-datasource": "",
+ "ds-error-boundary": {
+ "description": "",
+ "title": ""
+ },
"filter-view": {
"no-more-results": "",
"no-rules-found": ""
},
- "new-alert-rule": ""
+ "new-alert-rule": "",
+ "pagination": {
+ "next-page": "",
+ "previous-page": ""
+ },
+ "return-button": {
+ "title": ""
+ },
+ "rulerrule-loading-error": ""
},
"rule-state": {
"creating": "",
@@ -690,7 +722,10 @@
"title": ""
},
"custom-value": {
- "label": ""
+ "description": ""
+ },
+ "group": {
+ "undefined": ""
},
"options": {
"no-found": ""
@@ -863,6 +898,82 @@
"redirect-link": "Lista no alerta do Grafana",
"subtitle": "Regras de alerta relacionadas a este painel de controle"
},
+ "default-layout": {
+ "description": "",
+ "item-options": {
+ "repeat": {
+ "direction": {
+ "horizontal": "",
+ "title": "",
+ "vertical": ""
+ },
+ "max": "",
+ "title": "",
+ "variable": {
+ "description": "",
+ "title": ""
+ }
+ }
+ },
+ "name": "",
+ "row-actions": {
+ "delete": "",
+ "modal": {
+ "alt-action": "",
+ "text": "",
+ "title": ""
+ }
+ },
+ "row-options": {
+ "button": {
+ "label": ""
+ },
+ "form": {
+ "cancel": "",
+ "repeat-for": {
+ "label": "",
+ "learn-more": "",
+ "warning": {
+ "text": ""
+ }
+ },
+ "title": "",
+ "update": ""
+ },
+ "modal": {
+ "title": ""
+ }
+ }
+ },
+ "edit-pane": {
+ "objects": {
+ "multi-select": {
+ "selection-number": ""
+ }
+ },
+ "open": "",
+ "panels": {
+ "multi-select": {
+ "selection-number": ""
+ }
+ },
+ "row": {
+ "header": {
+ "hide": "",
+ "title": ""
+ },
+ "multi-select": {
+ "options-header": "",
+ "selection-number": ""
+ }
+ },
+ "tab": {
+ "multi-select": {
+ "options-header": "",
+ "selection-number": ""
+ }
+ }
+ },
"empty": {
"add-library-panel-body": "Adicione visualizações que são compartilhadas com outros painéis de controle.",
"add-library-panel-button": "Adicionar painel de biblioteca",
@@ -874,6 +985,9 @@
"import-a-dashboard-header": "Importar um painel de controle",
"import-dashboard-button": "Importar painel de controle"
},
+ "errors": {
+ "failed-to-load": ""
+ },
"inspect": {
"data-tab": "Dados",
"error-tab": "Erro",
@@ -926,19 +1040,130 @@
"rows": "Número total de linhas",
"table-title": "Estatísticas"
},
+ "options": {
+ "description": "",
+ "title": "",
+ "title-option": ""
+ },
+ "panel-edit": {
+ "alerting-tab": {
+ "dashboard-not-saved": "",
+ "no-rules": ""
+ }
+ },
+ "responsive-layout": {
+ "description": "",
+ "item-options": {
+ "hide-no-data": "",
+ "title": ""
+ },
+ "name": "",
+ "options": {
+ "columns": "",
+ "fixed": "",
+ "min": "",
+ "one-column": "",
+ "rows": "",
+ "three-columns": "",
+ "two-columns": ""
+ }
+ },
+ "rows-layout": {
+ "description": "",
+ "name": "",
+ "row": {
+ "collapse": "",
+ "expand": "",
+ "new": "",
+ "repeat": {
+ "learn-more": "",
+ "warning": ""
+ }
+ },
+ "row-options": {
+ "height": {
+ "expand": "",
+ "hide-row-header": "",
+ "min": "",
+ "title": ""
+ },
+ "repeat": {
+ "title": "",
+ "variable": {
+ "title": ""
+ }
+ },
+ "title": "",
+ "title-option": ""
+ }
+ },
+ "tabs-layout": {
+ "description": "",
+ "name": "",
+ "tab": {
+ "new": ""
+ },
+ "tab-options": {
+ "title": "",
+ "title-option": ""
+ }
+ },
"toolbar": {
"add": "Adicionar",
+ "add-panel": "",
+ "add-panel-lib": "",
+ "add-row": "",
+ "add-tab": "",
"alert-rules": "Regras de alerta",
+ "back-to-dashboard": "",
+ "dashboard-settings": {
+ "label": "",
+ "tooltip": ""
+ },
+ "discard-library-panel-changes": "",
+ "discard-panel": "",
+ "discard-panel-new": "",
+ "edit": {
+ "label": "",
+ "tooltip": ""
+ },
+ "edit-dashboard-v2-schema": "",
+ "enter-edit-mode": {
+ "label": "",
+ "tooltip": ""
+ },
+ "exit-edit-mode": {
+ "label": "",
+ "tooltip": ""
+ },
"mark-favorite": "Marcar como favorito",
+ "more-save-options": "",
"open-original": "Abrir painel de controle original",
"playlist-next": "Ir para o próximo painel de controle",
"playlist-previous": "Ir para o painel de controle anterior",
"playlist-stop": "Parar lista de reprodução",
+ "public-dashboard": "",
"refresh": "Atualizar painel de controle",
"save": "Salvar painel de controle",
+ "save-dashboard": {
+ "label": "",
+ "tooltip": ""
+ },
+ "save-dashboard-copy": {
+ "label": "",
+ "tooltip": ""
+ },
+ "save-dashboard-short": "",
+ "save-library-panel": "",
"settings": "Configurações do painel de controle",
- "share": "Compartilhar painel de controle",
+ "share": {
+ "label": "",
+ "tooltip": ""
+ },
"share-button": "Compartilhar",
+ "show-hidden-elements": "",
+ "switch-old-dashboard": "",
+ "unlink-library-panel": "",
"unmark-favorite": "Desmarcar como favorito"
},
"validation": {
@@ -946,6 +1171,14 @@
"invalid-json": "JSON inválido",
"tags-expected-array": "matriz de tags esperada",
"tags-expected-strings": "matriz de strings de tags esperada"
+ },
+ "viz-panel": {
+ "options": {
+ "description": "",
+ "title": "",
+ "title-option": "",
+ "transparent-background": ""
+ }
}
},
"dashboard-import": {
@@ -1048,6 +1281,12 @@
"angular-deprecation-description": "",
"angular-deprecation-heading": ""
},
+ "panel-queries": {
+ "add-query-from-library": ""
+ },
+ "query-library": {
+ "add-query-button": ""
+ },
"settings": {
"variables": {
"dependencies": {
@@ -1125,40 +1364,11 @@
"scan-for-older-logs": "",
"stop-scan": ""
},
- "query-library": {
- "add-edit-description": "",
- "cancel": "",
- "default-description": "",
- "delete-query": "",
- "delete-query-text": "",
- "delete-query-title": "",
- "private": "",
- "public": "",
- "query-deleted": "",
- "query-template-add-error": "",
- "query-template-added": "",
- "query-template-edit-error": "",
- "query-template-edited": "",
- "save": ""
- },
- "query-template-modal": {
- "add-info": "",
- "add-title": "",
- "auto-star": "",
- "data-source-name": "",
- "description": "",
- "edit-info": "",
- "edit-title": "",
- "query": "",
- "visibility": ""
- },
- "query-template-modall": {
- "data-source-type": ""
- },
"rich-history": {
"close-tooltip": "Fechar histórico de consultas",
"datasource-a-z": "Fonte de dados A-Z",
"datasource-z-a": "Fonte de dados Z-A",
+ "library-history-dropdown": "",
"newest-first": "Mais recentes primeiro",
"oldest-first": "Mais antigos primeiro",
"query-history": "Histórico de consultas",
@@ -1305,10 +1515,9 @@
"sortBy": ""
},
"related-logs": {
- "docsLink": "",
+ "LrrDocsLink": "",
"openExploreLogs": "",
- "relatedLogsUnavailableAfterDocsLink": "",
- "relatedLogsUnavailableBeforeDocsLink": "",
+ "relatedLogsUnavailable": "",
"warnExperimentalFeature": ""
},
"viewBy": ""
@@ -1357,6 +1566,24 @@
"send-custom-feedback": ""
},
"grafana-ui": {
+ "action-editor": {
+ "button": {
+ "confirm": "",
+ "confirm-action": ""
+ },
+ "inline": {
+ "add-action": "",
+ "edit-action": ""
+ },
+ "modal": {
+ "action-body": "",
+ "action-method": "",
+ "action-query-params": "",
+ "action-title": "",
+ "action-title-placeholder": "",
+ "one-click-description": ""
+ }
+ },
"auto-save-field": {
"saved": "",
"saving": ""
@@ -1373,11 +1600,21 @@
},
"data-link-editor-modal": {
"cancel": "",
+ "one-click-description": "",
"save": ""
},
+ "data-link-inline-editor": {
+ "one-click": ""
+ },
"data-links-inline-editor": {
"add-link": "",
- "one-click-link": ""
+ "edit-link": "",
+ "one-click": "",
+ "one-click-enabled": "",
+ "title-not-provided": "",
+ "tooltip-edit": "",
+ "tooltip-remove": "",
+ "url-not-provided": ""
},
"data-source-http-settings": {
"access-help": "",
@@ -1458,7 +1695,12 @@
"right-axis-indicator": ""
},
"viz-tooltip": {
- "footer-add-annotation": ""
+ "actions-confirmation-input-placeholder": "",
+ "actions-confirmation-label": "",
+ "actions-confirmation-message": "",
+ "footer-add-annotation": "",
+ "footer-click-to-action": "",
+ "footer-click-to-navigate": ""
}
},
"graph": {
@@ -1780,7 +2022,8 @@
},
"log-row-message": {
"ellipsis": "",
- "more": ""
+ "more": "",
+ "see-details": ""
},
"log-rows": {
"disable-popover": {
@@ -1792,6 +2035,13 @@
"shortcut": ""
}
},
+ "logs-navigation": {
+ "newer-logs": "",
+ "older-logs": "",
+ "scroll-bottom": "",
+ "scroll-top": "",
+ "start-of-range": ""
+ },
"popover-menu": {
"copy": "",
"disable-menu": "",
@@ -1881,6 +2131,7 @@
"title": ""
},
"migrated-counts": {
+ "alert_rule_groups": "",
"alert_rules": "",
"contact_points": "",
"dashboards": "",
@@ -1941,9 +2192,8 @@
"public-preview": {
"button-text": "",
"message": "",
- "message-plugins": "",
- "title": "",
- "title-plugins": ""
+ "message-cloud": "",
+ "title": ""
},
"resource-details": {
"dismiss-button": "",
@@ -1986,6 +2236,7 @@
},
"resource-type": {
"alert_rule": "",
+ "alert_rule_group": "",
"contact_point": "",
"dashboard": "Painel de controle",
"datasource": "Fonte de dados",
@@ -2031,6 +2282,15 @@
"title": "Por que hospedar com o Grafana?"
}
},
+ "multicombobox": {
+ "all": {
+ "title": "",
+ "title-filtered": ""
+ },
+ "clear": {
+ "title": ""
+ }
+ },
"nav": {
"add-new-connections": {
"title": "Adicionar nova conexão"
@@ -2384,7 +2644,8 @@
"list-label": "Navegação",
"open": "",
"undock": "Desacoplar menu"
- }
+ },
+ "rss-button": ""
},
"news": {
"drawer": {
@@ -2516,19 +2777,35 @@
}
},
"details": {
+ "connections-tab": {
+ "description": ""
+ },
"labels": {
"contactGrafanaLabs": "",
+ "customLinks": "",
+ "customLinksTooltip": "",
"dependencies": "",
+ "documentation": "",
"downloads": "",
"from": "",
"installedVersion": "",
"lastCommitDate": "",
"latestVersion": "",
- "links": "",
+ "license": "",
+ "raiseAnIssue": "",
"reportAbuse": "",
+ "reportAbuseTooltip": "",
+ "repository": "",
"signature": "",
"status": "",
"updatedAt": ""
+ },
+ "modal": {
+ "cancel": "",
+ "copyEmail": "",
+ "description": "",
+ "node": "",
+ "title": ""
}
},
"empty-state": {
@@ -2743,14 +3020,6 @@
"role-label": "Função"
}
},
- "query-library": {
- "datasource-names": "",
- "delete-query-button": "",
- "query-template-get-error": "",
- "search": "",
- "user-info-get-error": "",
- "user-names": ""
- },
"query-operation": {
"header": {
"collapse-row": "Recolher linha de consulta",
@@ -2760,7 +3029,6 @@
"expand-row": "Expandir linha de consulta",
"hide-response": "",
"remove-query": "Remover consulta",
- "save-to-query-library": "",
"show-response": "",
"toggle-edit-mode": "Alternar modo de edição de texto"
},
@@ -2862,6 +3130,10 @@
"title": ""
},
"save-dashboards": {
+ "message-length": {
+ "info": "",
+ "title": ""
+ },
"name-exists": {
"message-info": "",
"message-suggestion": "",
@@ -3145,6 +3417,7 @@
"home-dashboard-placeholder": "Painel de controle padrão",
"locale-label": "Idioma",
"locale-placeholder": "Escolher idioma",
+ "theme-description": "",
"theme-label": "Tema da interface",
"week-start-label": "Início da semana"
},
@@ -3216,6 +3489,13 @@
"url-column-header": "URL da captura",
"view-button": "Visualizar"
},
+ "table": {
+ "container": {
+ "content": "",
+ "show-all-series": "",
+ "show-only-series": ""
+ }
+ },
"tag-filter": {
"clear-button": "",
"loading": "Carregando...",
@@ -3309,20 +3589,26 @@
"start-your-metrics-exploration": "",
"subtitle": ""
},
- "metric-overview": {
- "description-label": "",
- "labels": "",
- "metric-attributes": "",
- "no-description": "",
- "type-label": "",
- "unit-label": "",
- "unknown-type": ""
- },
"metric-select": {
"filter-by": "",
+ "native-histogram": "",
"new-badge": "",
"otel-switch": ""
},
+ "native-histogram-banner": {
+ "ch-heatmap": "",
+ "ch-histogram": "",
+ "click-histogram": "",
+ "hide-examples": "",
+ "learn-more": "",
+ "metric-examples": "",
+ "nh-heatmap": "",
+ "nh-histogram": "",
+ "now": "",
+ "previously": "",
+ "see-examples": "",
+ "sentence": ""
+ },
"recent-metrics": {
"or-view-a-recent-exploration": ""
},
diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json
index c5cba66ace2..ba17087b1e7 100644
--- a/public/locales/zh-Hans/grafana.json
+++ b/public/locales/zh-Hans/grafana.json
@@ -34,12 +34,6 @@
"save-button": ""
}
},
- "actions-editor": {
- "inline": {
- "add-button": "",
- "one-click-link": ""
- }
- },
"admin": {
"anon-users": {
"not-found": ""
@@ -174,6 +168,24 @@
}
},
"alerting": {
+ "alert": {
+ "alert-state": "",
+ "annotations": "",
+ "evaluation": "",
+ "evaluation-paused": "",
+ "evaluation-paused-description": "",
+ "last-evaluated": "",
+ "last-evaluation-duration": "",
+ "last-updated-at": "",
+ "last-updated-by": "",
+ "no-annotations": "",
+ "pending-period": "",
+ "rule": "",
+ "rule-identifier": "",
+ "rule-type": "",
+ "state-error-timeout": "",
+ "state-no-data": ""
+ },
"alert-recording-rule-form": {
"evaluation-behaviour": {
"description": {
@@ -281,6 +293,7 @@
"contactPointFilter": {
"label": ""
},
+ "copy-to-clipboard": "",
"export": {
"subtitle": {
"formats": "",
@@ -297,6 +310,13 @@
}
}
},
+ "group-actions": {
+ "actions-trigger": "",
+ "delete": "",
+ "edit": "",
+ "export": "",
+ "reorder": ""
+ },
"list-view": {
"empty": {
"new-alert-rule": "",
@@ -480,11 +500,23 @@
},
"rule-list": {
"configure-datasource": "",
+ "ds-error-boundary": {
+ "description": "",
+ "title": ""
+ },
"filter-view": {
"no-more-results": "",
"no-rules-found": ""
},
- "new-alert-rule": ""
+ "new-alert-rule": "",
+ "pagination": {
+ "next-page": "",
+ "previous-page": ""
+ },
+ "return-button": {
+ "title": ""
+ },
+ "rulerrule-loading-error": ""
},
"rule-state": {
"creating": "",
@@ -681,7 +713,10 @@
"title": ""
},
"custom-value": {
- "label": ""
+ "description": ""
+ },
+ "group": {
+ "undefined": ""
},
"options": {
"no-found": ""
@@ -854,6 +889,82 @@
"redirect-link": "Grafana Alerting 中的列表",
"subtitle": "与此仪表板相关的警报规则"
},
+ "default-layout": {
+ "description": "",
+ "item-options": {
+ "repeat": {
+ "direction": {
+ "horizontal": "",
+ "title": "",
+ "vertical": ""
+ },
+ "max": "",
+ "title": "",
+ "variable": {
+ "description": "",
+ "title": ""
+ }
+ }
+ },
+ "name": "",
+ "row-actions": {
+ "delete": "",
+ "modal": {
+ "alt-action": "",
+ "text": "",
+ "title": ""
+ }
+ },
+ "row-options": {
+ "button": {
+ "label": ""
+ },
+ "form": {
+ "cancel": "",
+ "repeat-for": {
+ "label": "",
+ "learn-more": "",
+ "warning": {
+ "text": ""
+ }
+ },
+ "title": "",
+ "update": ""
+ },
+ "modal": {
+ "title": ""
+ }
+ }
+ },
+ "edit-pane": {
+ "objects": {
+ "multi-select": {
+ "selection-number": ""
+ }
+ },
+ "open": "",
+ "panels": {
+ "multi-select": {
+ "selection-number": ""
+ }
+ },
+ "row": {
+ "header": {
+ "hide": "",
+ "title": ""
+ },
+ "multi-select": {
+ "options-header": "",
+ "selection-number": ""
+ }
+ },
+ "tab": {
+ "multi-select": {
+ "options-header": "",
+ "selection-number": ""
+ }
+ }
+ },
"empty": {
"add-library-panel-body": "添加与其他仪表板共享的可视化。",
"add-library-panel-button": "添加库面板",
@@ -865,6 +976,9 @@
"import-a-dashboard-header": "导入仪表板",
"import-dashboard-button": "导入仪表板"
},
+ "errors": {
+ "failed-to-load": ""
+ },
"inspect": {
"data-tab": "数据",
"error-tab": "错误",
@@ -917,19 +1031,130 @@
"rows": "总行数",
"table-title": "统计信息"
},
+ "options": {
+ "description": "",
+ "title": "",
+ "title-option": ""
+ },
+ "panel-edit": {
+ "alerting-tab": {
+ "dashboard-not-saved": "",
+ "no-rules": ""
+ }
+ },
+ "responsive-layout": {
+ "description": "",
+ "item-options": {
+ "hide-no-data": "",
+ "title": ""
+ },
+ "name": "",
+ "options": {
+ "columns": "",
+ "fixed": "",
+ "min": "",
+ "one-column": "",
+ "rows": "",
+ "three-columns": "",
+ "two-columns": ""
+ }
+ },
+ "rows-layout": {
+ "description": "",
+ "name": "",
+ "row": {
+ "collapse": "",
+ "expand": "",
+ "new": "",
+ "repeat": {
+ "learn-more": "",
+ "warning": ""
+ }
+ },
+ "row-options": {
+ "height": {
+ "expand": "",
+ "hide-row-header": "",
+ "min": "",
+ "title": ""
+ },
+ "repeat": {
+ "title": "",
+ "variable": {
+ "title": ""
+ }
+ },
+ "title": "",
+ "title-option": ""
+ }
+ },
+ "tabs-layout": {
+ "description": "",
+ "name": "",
+ "tab": {
+ "new": ""
+ },
+ "tab-options": {
+ "title": "",
+ "title-option": ""
+ }
+ },
"toolbar": {
"add": "添加",
+ "add-panel": "",
+ "add-panel-lib": "",
+ "add-row": "",
+ "add-tab": "",
"alert-rules": "警报规则",
+ "back-to-dashboard": "",
+ "dashboard-settings": {
+ "label": "",
+ "tooltip": ""
+ },
+ "discard-library-panel-changes": "",
+ "discard-panel": "",
+ "discard-panel-new": "",
+ "edit": {
+ "label": "",
+ "tooltip": ""
+ },
+ "edit-dashboard-v2-schema": "",
+ "enter-edit-mode": {
+ "label": "",
+ "tooltip": ""
+ },
+ "exit-edit-mode": {
+ "label": "",
+ "tooltip": ""
+ },
"mark-favorite": "标记为收藏",
+ "more-save-options": "",
"open-original": "打开原始仪表板",
"playlist-next": "前往下一个仪表板",
"playlist-previous": "前往上一个仪表板",
"playlist-stop": "停止播放列表",
+ "public-dashboard": "",
"refresh": "刷新仪表板",
"save": "保存仪表板",
+ "save-dashboard": {
+ "label": "",
+ "tooltip": ""
+ },
+ "save-dashboard-copy": {
+ "label": "",
+ "tooltip": ""
+ },
+ "save-dashboard-short": "",
+ "save-library-panel": "",
"settings": "仪表板设置",
- "share": "分享仪表板或面板",
+ "share": {
+ "label": "",
+ "tooltip": ""
+ },
"share-button": "分享",
+ "show-hidden-elements": "",
+ "switch-old-dashboard": "",
+ "unlink-library-panel": "",
"unmark-favorite": "取消标记为收藏"
},
"validation": {
@@ -937,6 +1162,14 @@
"invalid-json": "无有效的 JSON",
"tags-expected-array": "标签预期数组",
"tags-expected-strings": "标签预期字符串数组"
+ },
+ "viz-panel": {
+ "options": {
+ "description": "",
+ "title": "",
+ "title-option": "",
+ "transparent-background": ""
+ }
}
},
"dashboard-import": {
@@ -1039,6 +1272,12 @@
"angular-deprecation-description": "",
"angular-deprecation-heading": ""
},
+ "panel-queries": {
+ "add-query-from-library": ""
+ },
+ "query-library": {
+ "add-query-button": ""
+ },
"settings": {
"variables": {
"dependencies": {
@@ -1116,40 +1355,11 @@
"scan-for-older-logs": "",
"stop-scan": ""
},
- "query-library": {
- "add-edit-description": "",
- "cancel": "",
- "default-description": "",
- "delete-query": "",
- "delete-query-text": "",
- "delete-query-title": "",
- "private": "",
- "public": "",
- "query-deleted": "",
- "query-template-add-error": "",
- "query-template-added": "",
- "query-template-edit-error": "",
- "query-template-edited": "",
- "save": ""
- },
- "query-template-modal": {
- "add-info": "",
- "add-title": "",
- "auto-star": "",
- "data-source-name": "",
- "description": "",
- "edit-info": "",
- "edit-title": "",
- "query": "",
- "visibility": ""
- },
- "query-template-modall": {
- "data-source-type": ""
- },
"rich-history": {
"close-tooltip": "关闭查询历史记录",
"datasource-a-z": "数据源 A-Z",
"datasource-z-a": "数据源 Z-A",
+ "library-history-dropdown": "",
"newest-first": "最新在前",
"oldest-first": "最早在前",
"query-history": "查询历史记录",
@@ -1296,10 +1506,9 @@
"sortBy": ""
},
"related-logs": {
- "docsLink": "",
+ "LrrDocsLink": "",
"openExploreLogs": "",
- "relatedLogsUnavailableAfterDocsLink": "",
- "relatedLogsUnavailableBeforeDocsLink": "",
+ "relatedLogsUnavailable": "",
"warnExperimentalFeature": ""
},
"viewBy": ""
@@ -1348,6 +1557,24 @@
"send-custom-feedback": ""
},
"grafana-ui": {
+ "action-editor": {
+ "button": {
+ "confirm": "",
+ "confirm-action": ""
+ },
+ "inline": {
+ "add-action": "",
+ "edit-action": ""
+ },
+ "modal": {
+ "action-body": "",
+ "action-method": "",
+ "action-query-params": "",
+ "action-title": "",
+ "action-title-placeholder": "",
+ "one-click-description": ""
+ }
+ },
"auto-save-field": {
"saved": "",
"saving": ""
@@ -1364,11 +1591,21 @@
},
"data-link-editor-modal": {
"cancel": "",
+ "one-click-description": "",
"save": ""
},
+ "data-link-inline-editor": {
+ "one-click": ""
+ },
"data-links-inline-editor": {
"add-link": "",
- "one-click-link": ""
+ "edit-link": "",
+ "one-click": "",
+ "one-click-enabled": "",
+ "title-not-provided": "",
+ "tooltip-edit": "",
+ "tooltip-remove": "",
+ "url-not-provided": ""
},
"data-source-http-settings": {
"access-help": "",
@@ -1449,7 +1686,12 @@
"right-axis-indicator": ""
},
"viz-tooltip": {
- "footer-add-annotation": ""
+ "actions-confirmation-input-placeholder": "",
+ "actions-confirmation-label": "",
+ "actions-confirmation-message": "",
+ "footer-add-annotation": "",
+ "footer-click-to-action": "",
+ "footer-click-to-navigate": ""
}
},
"graph": {
@@ -1770,7 +2012,8 @@
},
"log-row-message": {
"ellipsis": "",
- "more": ""
+ "more": "",
+ "see-details": ""
},
"log-rows": {
"disable-popover": {
@@ -1782,6 +2025,13 @@
"shortcut": ""
}
},
+ "logs-navigation": {
+ "newer-logs": "",
+ "older-logs": "",
+ "scroll-bottom": "",
+ "scroll-top": "",
+ "start-of-range": ""
+ },
"popover-menu": {
"copy": "",
"disable-menu": "",
@@ -1871,6 +2121,7 @@
"title": ""
},
"migrated-counts": {
+ "alert_rule_groups": "",
"alert_rules": "",
"contact_points": "",
"dashboards": "",
@@ -1931,9 +2182,8 @@
"public-preview": {
"button-text": "",
"message": "",
- "message-plugins": "",
- "title": "",
- "title-plugins": ""
+ "message-cloud": "",
+ "title": ""
},
"resource-details": {
"dismiss-button": "",
@@ -1976,6 +2226,7 @@
},
"resource-type": {
"alert_rule": "",
+ "alert_rule_group": "",
"contact_point": "",
"dashboard": "仪表板",
"datasource": "数据源",
@@ -2021,6 +2272,15 @@
"title": "为什么使用 Grafana 托管?"
}
},
+ "multicombobox": {
+ "all": {
+ "title": "",
+ "title-filtered": ""
+ },
+ "clear": {
+ "title": ""
+ }
+ },
"nav": {
"add-new-connections": {
"title": "添加新连接"
@@ -2374,7 +2634,8 @@
"list-label": "导航",
"open": "",
"undock": "取消停靠菜单"
- }
+ },
+ "rss-button": ""
},
"news": {
"drawer": {
@@ -2506,19 +2767,35 @@
}
},
"details": {
+ "connections-tab": {
+ "description": ""
+ },
"labels": {
"contactGrafanaLabs": "",
+ "customLinks": "",
+ "customLinksTooltip": "",
"dependencies": "",
+ "documentation": "",
"downloads": "",
"from": "",
"installedVersion": "",
"lastCommitDate": "",
"latestVersion": "",
- "links": "",
+ "license": "",
+ "raiseAnIssue": "",
"reportAbuse": "",
+ "reportAbuseTooltip": "",
+ "repository": "",
"signature": "",
"status": "",
"updatedAt": ""
+ },
+ "modal": {
+ "cancel": "",
+ "copyEmail": "",
+ "description": "",
+ "node": "",
+ "title": ""
}
},
"empty-state": {
@@ -2732,14 +3009,6 @@
"role-label": "角色"
}
},
- "query-library": {
- "datasource-names": "",
- "delete-query-button": "",
- "query-template-get-error": "",
- "search": "",
- "user-info-get-error": "",
- "user-names": ""
- },
"query-operation": {
"header": {
"collapse-row": "折叠查询行",
@@ -2749,7 +3018,6 @@
"expand-row": "展开查询行",
"hide-response": "",
"remove-query": "删除查询",
- "save-to-query-library": "",
"show-response": "",
"toggle-edit-mode": "切换文本编辑模式"
},
@@ -2848,6 +3116,10 @@
"title": ""
},
"save-dashboards": {
+ "message-length": {
+ "info": "",
+ "title": ""
+ },
"name-exists": {
"message-info": "",
"message-suggestion": "",
@@ -3131,6 +3403,7 @@
"home-dashboard-placeholder": "默认仪表板",
"locale-label": "语言",
"locale-placeholder": "选择语言",
+ "theme-description": "",
"theme-label": "UI 主题",
"week-start-label": "每周开始日"
},
@@ -3202,6 +3475,13 @@
"url-column-header": "快照网址",
"view-button": "查看"
},
+ "table": {
+ "container": {
+ "content": "",
+ "show-all-series": "",
+ "show-only-series": ""
+ }
+ },
"tag-filter": {
"clear-button": "",
"loading": "加载中...",
@@ -3295,20 +3575,26 @@
"start-your-metrics-exploration": "",
"subtitle": ""
},
- "metric-overview": {
- "description-label": "",
- "labels": "",
- "metric-attributes": "",
- "no-description": "",
- "type-label": "",
- "unit-label": "",
- "unknown-type": ""
- },
"metric-select": {
"filter-by": "",
+ "native-histogram": "",
"new-badge": "",
"otel-switch": ""
},
+ "native-histogram-banner": {
+ "ch-heatmap": "",
+ "ch-histogram": "",
+ "click-histogram": "",
+ "hide-examples": "",
+ "learn-more": "",
+ "metric-examples": "",
+ "nh-heatmap": "",
+ "nh-histogram": "",
+ "now": "",
+ "previously": "",
+ "see-examples": "",
+ "sentence": ""
+ },
"recent-metrics": {
"or-view-a-recent-exploration": ""
},
diff --git a/scripts/cli/reportBettererStats.mjs b/scripts/cli/reportBettererStats.ts
similarity index 74%
rename from scripts/cli/reportBettererStats.mjs
rename to scripts/cli/reportBettererStats.ts
index 0f1eda1d646..135f98c7752 100644
--- a/scripts/cli/reportBettererStats.mjs
+++ b/scripts/cli/reportBettererStats.ts
@@ -1,8 +1,7 @@
-// @ts-check
import { betterer } from '@betterer/betterer';
-import _ from 'lodash';
+import { camelCase } from 'lodash';
-function logStat(name, value) {
+function logStat(name: string, value: number) {
// Note that this output format must match the parsing in ci-frontend-metrics.sh
// which expects the two values to be separated by a space
console.log(`${name} ${value}`);
@@ -13,11 +12,11 @@ async function main() {
for (const testResults of results.resultSummaries) {
const countByMessage = {};
- const name = _.camelCase(testResults.name);
+ const name = camelCase(testResults.name);
Object.values(testResults.details)
.flatMap((v) => v)
.forEach((detail) => {
- const message = _.camelCase(detail.message);
+ const message = camelCase(detail.message);
const metricName = `${name}_${message}`;
if (metricName in countByMessage) {
countByMessage[metricName]++;
@@ -26,7 +25,7 @@ async function main() {
}
});
- for (const [metricName, count] of Object.entries(countByMessage)) {
+ for (const [metricName, count] of Object.entries(countByMessage)) {
logStat(metricName, count);
}
}
diff --git a/scripts/cli/tsconfig.json b/scripts/cli/tsconfig.json
index 9ad59a649d1..0d18e875c5e 100644
--- a/scripts/cli/tsconfig.json
+++ b/scripts/cli/tsconfig.json
@@ -6,6 +6,9 @@
"extends": "../../tsconfig.json",
"ts-node": {
"transpileOnly": true,
- "swc": true
+ "swc": true,
+ "compilerOptions": {
+ "module": "commonjs"
+ }
}
}
diff --git a/scripts/import_many_dashboards.sh b/scripts/import_many_dashboards.sh
index 60d4fcd55b3..47cbb2c36c2 100755
--- a/scripts/import_many_dashboards.sh
+++ b/scripts/import_many_dashboards.sh
@@ -3,6 +3,6 @@
for index in {0..3000}
do
echo -n "index $index"
- curl 'http://localhost:3000/api/dashboards/import' -H 'Pragma: no-cache' -H 'Origin: http://localhost:3000' -H 'Accept-Encoding: gzip, deflate' -H 'Accept-Language: en-US,en;q=0.8,sv;q=0.6' -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.101 Safari/537.36' -H 'Content-Type: application/json;charset=UTF-8' -H 'Accept: application/json, text/plain, */*' -H 'Cache-Control: no-cache' -H 'Referer: http://localhost:3000/dashboard/new?editview=import' -H 'Cookie: grafana_sess=662a67f11b47e657; grafana_user=admin; grafana_remember=bd839923f24f648c7cb53ede6ff9ef40826204e9a22df8f9; toggles=%7B%7D' -H 'Connection: keep-alive' --data-binary $'{"dashboard":{"__inputs":[{"name":"DS_GRAPHITE","label":"graphite","description":"","type":"datasource","pluginId":"graphite","pluginName":"Graphite"}],"__requires":[{"type":"panel","id":"singlestat","name":"Singlestat","version":""},{"type":"panel","id":"graph","name":"Graph","version":""},{"type":"grafana","id":"grafana","name":"Grafana","version":"3.1.0"},{"type":"datasource","id":"graphite","name":"Graphite","version":"1.0.0"}],"id":null,"title":"Big Dashboard dashname '"$index"$'","tags":["startpage","home","presentation"],"style":"dark","timezone":"browser","editable":true,"hideControls":false,"sharedCrosshair":true,"rows":[{"collapse":false,"editable":true,"height":"100px","panels":[{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":16,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"apps.backend.backend_02.counters.requests.count"}],"thresholds":"100,270","title":"Sign ups","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":15,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.7)"}],"thresholds":"100,270","title":"Logins","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":17,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"apps.backend.backend_04.counters.requests.count"}],"thresholds":"100,270","title":"Sign outs","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":18,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"scale(apps.backend.backend_03.counters.requests.count, 0.3)"}],"thresholds":"100,270","title":"Support calls","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1}],"title":"New row"},{"collapse":false,"editable":true,"height":218.4375,"panels":[{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":20,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.7)"}],"thresholds":"200,270","title":"Logins","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":24,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.2)"}],"thresholds":"200,270","title":"Google hits","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"bytes","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":22,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.4)"}],"thresholds":"200,270","title":"Memory","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":21,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.8)"}],"thresholds":"200,270","title":"Logouts","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":26,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.2)"}],"thresholds":"200,270","title":"Google hits","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":25,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.8)"}],"thresholds":"200,270","title":"Logouts","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1}],"title":"New row"},{"collapsable":true,"collapse":false,"editable":true,"height":"250px","notice":false,"panels":[{"aliasColors":{"cpu":"#E24D42","memory":"#6ED0E0","statsd.fakesite.counters.session_start.desktop.count":"#6ED0E0"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":3,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":4,"interactive":true,"legend":{"avg":false,"current":true,"max":false,"min":true,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[{"alias":"cpu","fill":0,"lines":true,"yaxis":2,"zindex":2},{"alias":"memory","pointradius":2,"points":true}],"span":4,"spyable":true,"stack":false,"steppedLine":false,"targets":[{"hide":false,"refId":"A","target":"alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.request_status.code_302.count, 10), 20), \'cpu\')"},{"refId":"B","target":"alias(statsd.fakesite.counters.session_start.desktop.count, \'memory\')"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"Memory / CPU","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"bytes","logBase":1,"max":null,"min":null,"show":true},{"format":"percent","logBase":1,"max":null,"min":0,"show":true}],"zerofill":true},{"aliasColors":{"logins":"#7EB26D","logins (-1 day)":"#447EBC"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":1,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":3,"interactive":true,"legend":{"alignAsTable":false,"avg":false,"current":true,"max":true,"min":true,"rightSide":false,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":1,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), 2), \'logins\')"},{"refId":"B","target":"alias(movingAverage(timeShift(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), \'1h\'), 2), \'logins (-1 hour)\')"}],"timeFrom":null,"timeShift":"1h","timezone":"browser","title":"logins","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"short","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true},{"aliasColors":{"cpu":"#E24D42","memory":"#6ED0E0","statsd.fakesite.counters.session_start.desktop.count":"#6ED0E0"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":3,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":19,"interactive":true,"legend":{"avg":false,"current":true,"max":false,"min":true,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[{"alias":"cpu","fill":0,"lines":true,"yaxis":2,"zindex":2},{"alias":"memory","pointradius":2,"points":true}],"span":4,"spyable":true,"stack":false,"steppedLine":false,"targets":[{"hide":false,"refId":"A","target":"alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.request_status.code_302.count, 10), 20), \'cpu\')"},{"refId":"B","target":"alias(statsd.fakesite.counters.session_start.desktop.count, \'memory\')"}],"timeFrom":null,"timeShift":"1h","timezone":"browser","title":"Memory / CPU","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"bytes","logBase":1,"max":null,"min":null,"show":true},{"format":"percent","logBase":1,"max":null,"min":0,"show":true}],"zerofill":true}],"title":"test"},{"collapsable":true,"collapse":false,"editable":true,"height":"300px","notice":false,"panels":[{"aliasColors":{"web_server_01":"#B7DBAB","web_server_02":"#7EB26D","web_server_03":"#508642","web_server_04":"#3F6833"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":8,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":2,"interactive":true,"legend":{"alignAsTable":false,"avg":false,"current":false,"max":false,"min":false,"rightSide":false,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(movingAverage(scaleToSeconds(apps.fakesite.*.counters.requests.count, 1), 2), 2)"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"server requests","tooltip":{"msResolution":false,"query_as_alias":true,"shared":true,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"short","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true},{"aliasColors":{"upper_25":"#F9E2D2","upper_50":"#F2C96D","upper_75":"#EAB839"},"annotate":{"enable":false},"bars":true,"datasource":"${DS_GRAPHITE}","editable":true,"fill":1,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":5,"interactive":true,"legend":{"alignAsTable":true,"avg":true,"current":false,"max":false,"min":false,"rightSide":true,"show":true,"total":false,"values":true},"legend_counts":true,"lines":false,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(summarize(statsd.fakesite.timers.ads_timer.*, \'4min\', \'avg\'), 4)"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"client side full page load","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"ms","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true},{"aliasColors":{"web_server_01":"#B7DBAB","web_server_02":"#7EB26D","web_server_03":"#508642","web_server_04":"#3F6833"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":8,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":14,"interactive":true,"legend":{"alignAsTable":false,"avg":false,"current":false,"max":false,"min":false,"rightSide":false,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(movingAverage(scaleToSeconds(apps.fakesite.*.counters.requests.count, 1), 2), 2)"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"server requests","tooltip":{"msResolution":false,"query_as_alias":true,"shared":true,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"short","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true}],"title":""},{"collapsable":true,"collapse":false,"editable":true,"height":"200px","notice":false,"panels":[{"aliasColors":{"cpu1":"#EF843C","cpu2":"#EAB839","upper_25":"#B7DBAB","upper_50":"#7EB26D","upper_75":"#629E51","upper_90":"#629E51","upper_95":"#508642"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":3,"grid":{"max":null,"min":null,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":6,"interactive":true,"legend":{"alignAsTable":true,"avg":true,"current":true,"legendSideLastValue":true,"max":false,"min":false,"rightSide":true,"show":false,"total":false,"values":true},"legend_counts":true,"lines":true,"linewidth":2,"links":[],"nullPointMode":"connected","options":false,"percentage":false,"pointradius":1,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[{"alias":"this is test of breaking","yaxis":1}],"span":12,"spyable":true,"stack":false,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(statsd.fakesite.timers.ads_timer.*,4)"},{"refId":"B","target":"alias(scale(statsd.fakesite.timers.ads_timer.upper_95,-1),\'cpu1\')"},{"refId":"C","target":"alias(scale(statsd.fakesite.timers.ads_timer.upper_75,-1),\'cpu2\')"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"transparent":true,"type":"graph","xaxis":{"show":false},"yaxes":[{"format":"ms","logBase":1,"max":null,"min":null,"show":false},{"format":"short","logBase":1,"max":null,"min":null,"show":false}],"zerofill":true}],"title":"test"}],"time":{"from":"now-30m","to":"now"},"timepicker":{"collapse":false,"enable":true,"notice":false,"now":true,"refresh_intervals":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"status":"Stable","time_options":["5m","15m","1h","2h"," 6h","12h","24h","2d","7d","30d"],"type":"timepicker"},"templating":{"enable":false,"list":[]},"annotations":{"enable":false,"list":[]},"refresh":false,"schemaVersion":12,"version":5,"links":[],"gnetId":null},"overwrite":true,"inputs":[{"name":"DS_GRAPHITE","type":"datasource","pluginId":"graphite","value":"graphite"}]}' --compressed
+ curl 'http://localhost:3000/api/dashboards/import' -H 'Pragma: no-cache' -H 'Origin: http://localhost:3000' -H 'Accept-Encoding: gzip, deflate' -H 'Accept-Language: en-US,en;q=0.8,sv;q=0.6' -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.101 Safari/537.36' -H 'Content-Type: application/json;charset=UTF-8' -H 'Accept: application/json, text/plain, */*' -H 'Cache-Control: no-cache' -H 'Referer: http://localhost:3000/dashboard/new?editview=import' -H 'Cookie: grafana_sess=662a67f11b47e657; grafana_user=admin; grafana_remember=bd839923f24f648c7cb53ede6ff9ef40826204e9a22df8f9; toggles=%7B%7D' -H 'Connection: keep-alive' --data-binary $'{"dashboard":{"__inputs":[{"name":"DS_GRAPHITE","label":"graphite","description":"","type":"datasource","pluginId":"graphite","pluginName":"Graphite"}],"__requires":[{"type":"panel","id":"singlestat","name":"Singlestat","version":""},{"type":"panel","id":"graph","name":"Graph","version":""},{"type":"grafana","id":"grafana","name":"Grafana","version":"3.1.0"},{"type":"datasource","id":"graphite","name":"Graphite","version":"1.0.0"}],"id":null,"title":"Big Dashboard dashname '"$index"$'","tags":["startpage","home","presentation"],"style":"dark","timezone":"browser","editable":true,"hideControls":false,"sharedCrosshair":true,"rows":[{"collapse":false,"editable":true,"height":"100px","panels":[{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":16,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"apps.backend.backend_02.counters.requests.count"}],"thresholds":"100,270","title":"Sign ups","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":15,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.7)"}],"thresholds":"100,270","title":"Logins","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":17,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"apps.backend.backend_04.counters.requests.count"}],"thresholds":"100,270","title":"Sign outs","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":18,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"scale(apps.backend.backend_03.counters.requests.count, 0.3)"}],"thresholds":"100,270","title":"Support calls","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1}],"title":"New row"},{"collapse":false,"editable":true,"height":218.4375,"panels":[{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":20,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.7)"}],"thresholds":"200,270","title":"Logins","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":24,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.2)"}],"thresholds":"200,270","title":"Google hits","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"bytes","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":22,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.4)"}],"thresholds":"200,270","title":"Memory","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":21,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.8)"}],"thresholds":"200,270","title":"Logouts","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":26,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.2)"}],"thresholds":"200,270","title":"Google hits","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":25,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.8)"}],"thresholds":"200,270","title":"Logouts","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1}],"title":"New row"},{"collapsable":true,"collapse":false,"editable":true,"height":"250px","notice":false,"panels":[{"aliasColors":{"cpu":"#E24D42","memory":"#6ED0E0","statsd.fakesite.counters.session_start.desktop.count":"#6ED0E0"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":3,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":4,"interactive":true,"legend":{"avg":false,"current":true,"max":false,"min":true,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[{"alias":"cpu","fill":0,"lines":true,"yaxis":2,"zindex":2},{"alias":"memory","pointradius":2,"points":true}],"span":4,"spyable":true,"stack":false,"steppedLine":false,"targets":[{"hide":false,"refId":"A","target":"alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.request_status.code_302.count, 10), 20), \'cpu\')"},{"refId":"B","target":"alias(statsd.fakesite.counters.session_start.desktop.count, \'memory\')"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"Memory / CPU","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"bytes","logBase":1,"max":null,"min":null,"show":true},{"format":"percent","logBase":1,"max":null,"min":0,"show":true}],"zerofill":true},{"aliasColors":{"logins":"#7EB26D","logins (-1 day)":"#447EBC"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":1,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":3,"interactive":true,"legend":{"alignAsTable":false,"avg":false,"current":true,"max":true,"min":true,"rightSide":false,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":1,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), 2), \'logins\')"},{"refId":"B","target":"alias(movingAverage(timeShift(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), \'1h\'), 2), \'logins (-1 hour)\')"}],"timeFrom":null,"timeShift":"1h","timezone":"browser","title":"logins","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"short","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true},{"aliasColors":{"cpu":"#E24D42","memory":"#6ED0E0","statsd.fakesite.counters.session_start.desktop.count":"#6ED0E0"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":3,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":19,"interactive":true,"legend":{"avg":false,"current":true,"max":false,"min":true,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[{"alias":"cpu","fill":0,"lines":true,"yaxis":2,"zindex":2},{"alias":"memory","pointradius":2,"points":true}],"span":4,"spyable":true,"stack":false,"steppedLine":false,"targets":[{"hide":false,"refId":"A","target":"alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.request_status.code_302.count, 10), 20), \'cpu\')"},{"refId":"B","target":"alias(statsd.fakesite.counters.session_start.desktop.count, \'memory\')"}],"timeFrom":null,"timeShift":"1h","timezone":"browser","title":"Memory / CPU","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"bytes","logBase":1,"max":null,"min":null,"show":true},{"format":"percent","logBase":1,"max":null,"min":0,"show":true}],"zerofill":true}],"title":"test"},{"collapsable":true,"collapse":false,"editable":true,"height":"300px","notice":false,"panels":[{"aliasColors":{"web_server_01":"#B7DBAB","web_server_02":"#7EB26D","web_server_03":"#508642","web_server_04":"#3F6833"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":8,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":2,"interactive":true,"legend":{"alignAsTable":false,"avg":false,"current":false,"max":false,"min":false,"rightSide":false,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(movingAverage(scaleToSeconds(apps.fakesite.*.counters.requests.count, 1), 2), 2)"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"server requests","tooltip":{"msResolution":false,"query_as_alias":true,"shared":true,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"short","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true},{"aliasColors":{"upper_25":"#F9E2D2","upper_50":"#F2C96D","upper_75":"#EAB839"},"annotate":{"enable":false},"bars":true,"datasource":"${DS_GRAPHITE}","editable":true,"fill":1,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":5,"interactive":true,"legend":{"alignAsTable":true,"avg":true,"current":false,"max":false,"min":false,"rightSide":true,"show":true,"total":false,"values":true},"legend_counts":true,"lines":false,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(summarize(statsd.fakesite.timers.ads_timer.*, \'4min\', \'avg\'), 4)"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"client side full page load","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"ms","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true},{"aliasColors":{"web_server_01":"#B7DBAB","web_server_02":"#7EB26D","web_server_03":"#508642","web_server_04":"#3F6833"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":8,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":14,"interactive":true,"legend":{"alignAsTable":false,"avg":false,"current":false,"max":false,"min":false,"rightSide":false,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(movingAverage(scaleToSeconds(apps.fakesite.*.counters.requests.count, 1), 2), 2)"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"server requests","tooltip":{"msResolution":false,"query_as_alias":true,"shared":true,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"short","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true}],"title":""},{"collapsable":true,"collapse":false,"editable":true,"height":"200px","notice":false,"panels":[{"aliasColors":{"cpu1":"#EF843C","cpu2":"#EAB839","upper_25":"#B7DBAB","upper_50":"#7EB26D","upper_75":"#629E51","upper_90":"#629E51","upper_95":"#508642"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":3,"grid":{"max":null,"min":null,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":6,"interactive":true,"legend":{"alignAsTable":true,"avg":true,"current":true,"legendSideLastValue":true,"max":false,"min":false,"rightSide":true,"show":false,"total":false,"values":true},"legend_counts":true,"lines":true,"linewidth":2,"links":[],"nullPointMode":"connected","options":false,"percentage":false,"pointradius":1,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[{"alias":"this is test of breaking","yaxis":1}],"span":12,"spyable":true,"stack":false,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(statsd.fakesite.timers.ads_timer.*,4)"},{"refId":"B","target":"alias(scale(statsd.fakesite.timers.ads_timer.upper_95,-1),\'cpu1\')"},{"refId":"C","target":"alias(scale(statsd.fakesite.timers.ads_timer.upper_75,-1),\'cpu2\')"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"transparent":true,"type":"graph","xaxis":{"show":false},"yaxes":[{"format":"ms","logBase":1,"max":null,"min":null,"show":false},{"format":"short","logBase":1,"max":null,"min":null,"show":false}],"zerofill":true}],"title":"test"}],"time":{"from":"now-30m","to":"now"},"timepicker":{"collapse":false,"enable":true,"notice":false,"now":true,"refresh_intervals":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"status":"Stable","type":"timepicker"},"templating":{"enable":false,"list":[]},"annotations":{"enable":false,"list":[]},"refresh":false,"schemaVersion":12,"version":5,"links":[],"gnetId":null},"overwrite":true,"inputs":[{"name":"DS_GRAPHITE","type":"datasource","pluginId":"graphite","value":"graphite"}]}' --compressed
done
diff --git a/tools/setup_grafana_alertmanager_integration_test_images.go b/tools/setup_grafana_alertmanager_integration_test_images.go
new file mode 100644
index 00000000000..3b6e1436446
--- /dev/null
+++ b/tools/setup_grafana_alertmanager_integration_test_images.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "sync"
+
+ amtests "github.com/grafana/grafana/pkg/tests/alertmanager"
+)
+
+func docker(args []string) {
+ cmd := exec.Command("docker", args...)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ fmt.Printf("docker pull failed: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func main() {
+ var wg sync.WaitGroup
+
+ for _, cmd := range [][]string{
+ {"pull", amtests.GetGrafanaImage()},
+ {"pull", amtests.GetLokiImage()},
+ {"pull", amtests.GetPostgresImage()},
+ {"build", "-t", "webhook-receiver", "devenv/docker/blocks/stateful_webhook"},
+ } {
+ wg.Add(1)
+
+ go func(cmd []string) {
+ defer wg.Done()
+
+ docker(cmd)
+ }(cmd)
+ }
+
+ wg.Wait()
+}
diff --git a/yarn.lock b/yarn.lock
index d5061c52875..49162fcfe61 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3814,11 +3814,11 @@ __metadata:
languageName: unknown
linkType: soft
-"@grafana/scenes-react@npm:5.42.0":
- version: 5.42.0
- resolution: "@grafana/scenes-react@npm:5.42.0"
+"@grafana/scenes-react@npm:6.0.2":
+ version: 6.0.2
+ resolution: "@grafana/scenes-react@npm:6.0.2"
dependencies:
- "@grafana/scenes": "npm:5.42.0"
+ "@grafana/scenes": "npm:6.0.2"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@@ -3829,20 +3829,21 @@ __metadata:
"@grafana/ui": ^11.0.0
react: ^18.0.0
react-dom: ^18.0.0
- checksum: 10/c94db6d57b02be5f960e44dc0c4be46e5e4708ede34f2f4e30934134543db88c8b553aceb89d9b3ae4dbf5d1eab1f48e534eb9a31d5c99b8908f715e45cb6dcf
+ react-router-dom: ^6.28.0
+ checksum: 10/9744e01f2ff912229e43cedfa41d626ccdfd034f5b9718b57c593bc90edadade960f76baf1d8ad19eed03709c17c62397df1871b89acc635172aa14f6a20e096
languageName: node
linkType: hard
-"@grafana/scenes@npm:5.42.0":
- version: 5.42.0
- resolution: "@grafana/scenes@npm:5.42.0"
+"@grafana/scenes@npm:6.0.2":
+ version: 6.0.2
+ resolution: "@grafana/scenes@npm:6.0.2"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
"@tanstack/react-virtual": "npm:^3.9.0"
- react-grid-layout: "npm:^1.3.4"
- react-use: "npm:^17.5.0"
- react-virtualized-auto-sizer: "npm:^1.0.24"
+ react-grid-layout: "npm:1.3.4"
+ react-use: "npm:17.5.0"
+ react-virtualized-auto-sizer: "npm:1.0.24"
uuid: "npm:^9.0.0"
peerDependencies:
"@grafana/data": ">=10.4"
@@ -3852,7 +3853,8 @@ __metadata:
"@grafana/ui": ">=10.4"
react: ^18.0.0
react-dom: ^18.0.0
- checksum: 10/3232a499b839a45c8eed924441e4423d1b1d9f6b4ea6725437d357c1afea93d9ab300c46319b1ddab76b0da1197905b22c3abe8095d78c3320b1befb4731bd24
+ react-router-dom: ^6.28.0
+ checksum: 10/2584f296db6299ef0a09d51f5c267ebcf7e44bd17b4d6516e38d3220f8f1d7aebc63c5fc6523979c4ac4d3f555416ca573e85e03bd36eb33a11941a5b3497149
languageName: node
linkType: hard
@@ -4122,7 +4124,7 @@ __metadata:
react-i18next: "npm:^15.0.0"
react-inlinesvg: "npm:4.1.5"
react-loading-skeleton: "npm:3.5.0"
- react-router-dom: "npm:5.3.3"
+ react-router-dom: "npm:5.3.4"
react-router-dom-v5-compat: "npm:^6.26.1"
react-select: "npm:5.10.0"
react-select-event: "npm:^5.1.0"
@@ -18149,8 +18151,8 @@ __metadata:
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/saga-icons": "workspace:*"
- "@grafana/scenes": "npm:5.42.0"
- "@grafana/scenes-react": "npm:5.42.0"
+ "@grafana/scenes": "npm:6.0.2"
+ "@grafana/scenes-react": "npm:6.0.2"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/tsconfig": "npm:^2.0.0"
@@ -18397,8 +18399,8 @@ __metadata:
react-refresh: "npm:0.14.0"
react-resizable: "npm:3.0.5"
react-responsive-carousel: "npm:^3.2.23"
- react-router: "npm:5.3.3"
- react-router-dom: "npm:5.3.3"
+ react-router: "npm:5.3.4"
+ react-router-dom: "npm:5.3.4"
react-router-dom-v5-compat: "npm:^6.26.1"
react-select: "npm:5.10.0"
react-select-event: "npm:5.5.1"
@@ -22461,19 +22463,6 @@ __metadata:
languageName: node
linkType: hard
-"mini-create-react-context@npm:^0.4.0":
- version: 0.4.1
- resolution: "mini-create-react-context@npm:0.4.1"
- dependencies:
- "@babel/runtime": "npm:^7.12.1"
- tiny-warning: "npm:^1.0.3"
- peerDependencies:
- prop-types: ^15.0.0
- react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
- checksum: 10/c816c785b7dccd67fdfa6a5edc673363b11845b6abca8a9d9f3ffa74520266d979b56f5db0dfc62ed912a90553c15be28c816311fc9c7856ab66a81d461d50e6
- languageName: node
- linkType: hard
-
"mini-css-extract-plugin@npm:2.9.2":
version: 2.9.2
resolution: "mini-css-extract-plugin@npm:2.9.2"
@@ -23004,7 +22993,7 @@ __metadata:
languageName: node
linkType: hard
-"nano-css@npm:^5.3.1, nano-css@npm:^5.6.2":
+"nano-css@npm:^5.3.1, nano-css@npm:^5.6.1, nano-css@npm:^5.6.2":
version: 5.6.2
resolution: "nano-css@npm:5.6.2"
dependencies:
@@ -26766,20 +26755,20 @@ __metadata:
languageName: node
linkType: hard
-"react-router-dom@npm:5.3.3":
- version: 5.3.3
- resolution: "react-router-dom@npm:5.3.3"
+"react-router-dom@npm:5.3.4":
+ version: 5.3.4
+ resolution: "react-router-dom@npm:5.3.4"
dependencies:
"@babel/runtime": "npm:^7.12.13"
history: "npm:^4.9.0"
loose-envify: "npm:^1.3.1"
prop-types: "npm:^15.6.2"
- react-router: "npm:5.3.3"
+ react-router: "npm:5.3.4"
tiny-invariant: "npm:^1.0.2"
tiny-warning: "npm:^1.0.0"
peerDependencies:
react: ">=15"
- checksum: 10/49552596f1a4c753b99324a5f4345b3ee91fbb780aa65851a7113f053044ef96c083d2ded12937e593b23a0fcdf58b9e49780df6bf6e27d9eeb348b3c85ae611
+ checksum: 10/5e0696ae2d86f466ff700944758a227e1dcd79b48797d567776506e4e3b4a08b81336155feb86a33be9f38c17c4d3d94212b5c60c8ee9a086022e4fd3961db29
languageName: node
linkType: hard
@@ -26796,15 +26785,14 @@ __metadata:
languageName: node
linkType: hard
-"react-router@npm:5.3.3":
- version: 5.3.3
- resolution: "react-router@npm:5.3.3"
+"react-router@npm:5.3.4":
+ version: 5.3.4
+ resolution: "react-router@npm:5.3.4"
dependencies:
"@babel/runtime": "npm:^7.12.13"
history: "npm:^4.9.0"
hoist-non-react-statics: "npm:^3.1.0"
loose-envify: "npm:^1.3.1"
- mini-create-react-context: "npm:^0.4.0"
path-to-regexp: "npm:^1.7.0"
prop-types: "npm:^15.6.2"
react-is: "npm:^16.6.0"
@@ -26812,7 +26800,7 @@ __metadata:
tiny-warning: "npm:^1.0.0"
peerDependencies:
react: ">=15"
- checksum: 10/4631eed91020c73950804c7c7454e74b2eb495f803c5ca60c8b5572ca72cc06e336f3b08d9ee3fa730128a52c4d9e16d1aa7e8b7f85560629117e16d99a01cef
+ checksum: 10/99d54a99af6bc6d7cad2e5ea7eee9485b62a8b8e16a1182b18daa7fad7dafa5e526850eaeebff629848b297ae055a9cb5b4aba8760e81af8b903efc049d48f5c
languageName: node
linkType: hard
@@ -27008,6 +26996,31 @@ __metadata:
languageName: node
linkType: hard
+"react-use@npm:17.5.0":
+ version: 17.5.0
+ resolution: "react-use@npm:17.5.0"
+ dependencies:
+ "@types/js-cookie": "npm:^2.2.6"
+ "@xobotyi/scrollbar-width": "npm:^1.9.5"
+ copy-to-clipboard: "npm:^3.3.1"
+ fast-deep-equal: "npm:^3.1.3"
+ fast-shallow-equal: "npm:^1.0.0"
+ js-cookie: "npm:^2.2.1"
+ nano-css: "npm:^5.6.1"
+ react-universal-interface: "npm:^0.6.2"
+ resize-observer-polyfill: "npm:^1.5.1"
+ screenfull: "npm:^5.1.0"
+ set-harmonic-interval: "npm:^1.0.1"
+ throttle-debounce: "npm:^3.0.1"
+ ts-easing: "npm:^0.2.0"
+ tslib: "npm:^2.1.0"
+ peerDependencies:
+ react: "*"
+ react-dom: "*"
+ checksum: 10/5d81fe0902303d3ed7810cdd56c6cae12b08124a3d4fcbfa3924327105b81447b039ea9d6aff20aac3c13999f949000870a7a2fa29fe20ed844ac26606462fa0
+ languageName: node
+ linkType: hard
+
"react-use@npm:17.5.1":
version: 17.5.1
resolution: "react-use@npm:17.5.1"
@@ -27069,7 +27082,17 @@ __metadata:
languageName: node
linkType: hard
-"react-virtualized-auto-sizer@npm:1.0.25, react-virtualized-auto-sizer@npm:^1.0.24, react-virtualized-auto-sizer@npm:^1.0.6":
+"react-virtualized-auto-sizer@npm:1.0.24":
+ version: 1.0.24
+ resolution: "react-virtualized-auto-sizer@npm:1.0.24"
+ peerDependencies:
+ react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0
+ react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0
+ checksum: 10/02101a340bdbe3e40c49dbc52e524eb7ca18832690e91f045a25675600d7adc0a63e800a4ace6a014132adcdcce0e12a8137971de408427a5a3112d7c87c9f3e
+ languageName: node
+ linkType: hard
+
+"react-virtualized-auto-sizer@npm:1.0.25, react-virtualized-auto-sizer@npm:^1.0.6":
version: 1.0.25
resolution: "react-virtualized-auto-sizer@npm:1.0.25"
peerDependencies:
@@ -30272,7 +30295,7 @@ __metadata:
languageName: node
linkType: hard
-"tiny-warning@npm:^1.0.0, tiny-warning@npm:^1.0.3":
+"tiny-warning@npm:^1.0.0":
version: 1.0.3
resolution: "tiny-warning@npm:1.0.3"
checksum: 10/da62c4acac565902f0624b123eed6dd3509bc9a8d30c06e017104bedcf5d35810da8ff72864400ad19c5c7806fc0a8323c68baf3e326af7cb7d969f846100d71